diff --git a/Core/Resgrid.AdminAssist/Catalog/security.yaml b/Core/Resgrid.AdminAssist/Catalog/security.yaml index 815a19d53..3447ee43a 100644 --- a/Core/Resgrid.AdminAssist/Catalog/security.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/security.yaml @@ -4320,6 +4320,336 @@ "EvidenceSource": "DepartmentSecurityPolicy", "Availability": "available" }, + { + "Id": "table.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "Binding": "DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowPasskeysForLoginMfa" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "Binding": "DepartmentSecurityPolicy.AllowPasskeysForAdp", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowPasskeysForAdp" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "Binding": "DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowFederatedMfaForLoginMfa" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "Binding": "DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowFederatedMfaForAdp" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "DefaultValue": "false", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowResponderApproval", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AllowResponderApproval", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AllowResponderApproval", + "Binding": "DepartmentSecurityPolicy.AllowResponderApproval", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AllowResponderApproval" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AllowResponderApproval", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "Binding": "DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AcceptRecentLoginMfaForAdp" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "Binding": "DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "ValueType": "boolean", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "AcceptRecentUnlockMfaForAdp" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "DefaultValue": "true", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "Binding": "DepartmentSecurityPolicy.SharedIdleLockMinutes", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "SharedIdleLockMinutes" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "DefaultValue": "5", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedShiftHours", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.SharedShiftHours", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.SharedShiftHours", + "Binding": "DepartmentSecurityPolicy.SharedShiftHours", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "SharedShiftHours" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.SharedShiftHours", + "DefaultValue": "12", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedModeRequiredApps", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSecurityPolicy.SharedModeRequiredApps", + "HelpKey": "TableHelp.DepartmentSecurityPolicy.SharedModeRequiredApps", + "Binding": "DepartmentSecurityPolicy.SharedModeRequiredApps", + "ValueType": "enum", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "SecurityPolicy", + "Field": "SharedModeRequiredApps" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSecurityPolicy.SharedModeRequiredApps", + "DefaultValue": "none", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSecurityPolicy", + "Availability": "available" + }, { "Id": "table.DepartmentSsoConfig.SsoProviderType", "AreaId": "security", @@ -4584,6 +4914,39 @@ "EvidenceSource": "DepartmentSsoConfig", "Availability": "reference-only" }, + { + "Id": "table.DepartmentSsoConfig.IdpSsoUrl", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.IdpSsoUrl", + "HelpKey": "TableHelp.DepartmentSsoConfig.IdpSsoUrl", + "Binding": "DepartmentSsoConfig.IdpSsoUrl", + "ValueType": "restricted", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.IdpSsoUrl", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, { "Id": "table.DepartmentSsoConfig.EncryptedIdpCertificate", "AreaId": "security", @@ -4683,6 +5046,39 @@ "EvidenceSource": "DepartmentSsoConfig", "Availability": "reference-only" }, + { + "Id": "table.DepartmentSsoConfig.FederatedMfaMappingJson", + "AreaId": "security", + "LabelKey": "TableField.DepartmentSsoConfig.FederatedMfaMappingJson", + "HelpKey": "TableHelp.DepartmentSsoConfig.FederatedMfaMappingJson", + "Binding": "DepartmentSsoConfig.FederatedMfaMappingJson", + "ValueType": "structured", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Security", + "Action": "Sso", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z", + "Impact": { + "Risk": "Critical", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "table.DepartmentSsoConfig.FederatedMfaMappingJson", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", + "EvidenceSource": "DepartmentSsoConfig", + "Availability": "reference-only" + }, { "Id": "table.DepartmentSsoConfig.AllowLocalLogin", "AreaId": "security", @@ -5088,6 +5484,13 @@ }, "Requirements": [], "SettingIds": [ + "table.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "table.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "table.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "table.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "table.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "table.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "table.DepartmentSecurityPolicy.AllowResponderApproval", "table.DepartmentSecurityPolicy.AllowedIpRanges", "table.DepartmentSecurityPolicy.DataClassificationLevel", "table.DepartmentSecurityPolicy.MaxConcurrentSessions", @@ -5096,7 +5499,10 @@ "table.DepartmentSecurityPolicy.RequireMfa", "table.DepartmentSecurityPolicy.RequirePasswordComplexity", "table.DepartmentSecurityPolicy.RequireSso", - "table.DepartmentSecurityPolicy.SessionTimeoutMinutes" + "table.DepartmentSecurityPolicy.SessionTimeoutMinutes", + "table.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "table.DepartmentSecurityPolicy.SharedModeRequiredApps", + "table.DepartmentSecurityPolicy.SharedShiftHours" ], "RuleIds": [], "Prominence": "Key", @@ -5129,6 +5535,8 @@ "table.DepartmentSsoConfig.EncryptedScimBearerToken", "table.DepartmentSsoConfig.EncryptedSigningCertificate", "table.DepartmentSsoConfig.EntityId", + "table.DepartmentSsoConfig.FederatedMfaMappingJson", + "table.DepartmentSsoConfig.IdpSsoUrl", "table.DepartmentSsoConfig.IsEnabled", "table.DepartmentSsoConfig.MetadataUrl", "table.DepartmentSsoConfig.ScimEnabled", @@ -6677,6 +7085,116 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" }, + { + "Id": "table.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowPasskeysForLoginMfa", + "Body": "Department Security Policy / Allow Passkeys For Login MFA. Accepts a passkey registered in the requesting app as MFA for sign-in, department entry and step-up. Authenticator codes are always accepted. Changing it advances the MFA policy version; sessions whose only second factor is now disallowed verify again. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowpasskeysforloginmfa", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowPasskeysForAdp", + "Body": "Department Security Policy / Allow Passkeys For ADP. Accepts a passkey for protected-data access, ADP management and protected-workflow approvals. Authenticator codes are always accepted. Changing it ends current protected-data access, so members verify again. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowpasskeysforadp", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowFederatedMfaForLoginMfa", + "Body": "Department Security Policy / Allow Federated MFA For Login MFA. Accepts the identity provider's MFA for sign-in and step-up. Requires a tested MFA mapping on the SSO configuration; without one the provider's claims never count. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowfederatedmfaforloginmfa", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowFederatedMfaForAdp", + "Body": "Department Security Policy / Allow Federated MFA For ADP. Accepts the identity provider's MFA for protected data. Requires a tested MFA mapping; changing it ends current protected-data access. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowfederatedmfaforadp", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AllowResponderApproval", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AllowResponderApproval", + "Body": "Department Security Policy / Allow Responder Approval. Accepts approval with the member's Responder app passkey wherever the matching passkey setting is on; never for security changes or account sign-in methods. Changing it also ends current protected-data access. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-allowresponderapproval", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AcceptRecentLoginMfaForAdp", + "Body": "Department Security Policy / Accept Recent Login MFA For ADP. Lets MFA completed at sign-in in the same session open protected data within the step-up window, for methods the protected-data settings allow. Changing it ends current protected-data access. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-acceptrecentloginmfaforadp", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.AcceptRecentUnlockMfaForAdp", + "Body": "Department Security Policy / Accept Recent Unlock MFA For ADP. Lets the operator's fresh unlock verification on a shared device open protected data. Changing it ends current protected-data access. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-acceptrecentunlockmfaforadp", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.SharedIdleLockMinutes", + "Body": "Department Security Policy / Shared Idle Lock Minutes. Minutes without operator activity before a shared vehicle tablet or workstation session locks, from 1 to 15. Background updates and incoming alerts are not activity. A stricter value reaches running sessions at their next request. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-sharedidlelockminutes", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedShiftHours", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.SharedShiftHours", + "Body": "Department Security Policy / Shared Shift Hours. Hours after sign-in when a shared session ends whatever the activity, from 1 to 24. A shorter value ends running sessions sooner; a longer one never extends them. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-sharedshifthours", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "table.DepartmentSecurityPolicy.SharedModeRequiredApps", + "Locale": "en", + "TitleKey": "TableField.DepartmentSecurityPolicy.SharedModeRequiredApps", + "Body": "Department Security Policy / Shared Mode Required Apps. Unit, IC and Dispatch sessions this department always runs as shared sessions, whatever the installation is set to; sign-ins that do not name their app count too. Needs app versions that support shared mode. Managing member only.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentsecuritypolicy-sharedmoderequiredapps", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, { "Id": "table.DepartmentSsoConfig.SsoProviderType", "Locale": "en", @@ -6765,6 +7283,17 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" }, + { + "Id": "table.DepartmentSsoConfig.IdpSsoUrl", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.IdpSsoUrl", + "Body": "Department SSO Config / IdP SSO URL. SAML provider sign-in address that sign-in started from Resgrid sends its request to. Verify it against the provider metadata before relying on it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-idpssourl", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, { "Id": "table.DepartmentSsoConfig.EncryptedIdpCertificate", "Locale": "en", @@ -6798,6 +7327,17 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" }, + { + "Id": "table.DepartmentSsoConfig.FederatedMfaMappingJson", + "Locale": "en", + "TitleKey": "TableField.DepartmentSsoConfig.FederatedMfaMappingJson", + "Body": "Department SSO Config / Federated MFA Mapping JSON. Which provider sign-in results count as MFA for provider step-up. A changed mapping, issuer or client needs a new successful test before provider MFA is accepted again.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "table-departmentssoconfig-federatedmfamappingjson", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-09-24T00:00:00Z" + }, { "Id": "table.DepartmentSsoConfig.AllowLocalLogin", "Locale": "en", diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index f817141ce..3fb61db24 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -42,24 +42,48 @@ public static class DataProtectionConfig public static int BrokerTimeoutMs = 10000; /// - /// 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 (, passkey plan section 8.5); the broker accepts it only + /// while is on, with full authority, logging every use. A client + /// sends it only when it has no . Supplied through the environment only. /// public static string BrokerApiKey = ""; + /// + /// Broker side (passkey plan section 8.5): one credential per calling host role, as + /// id=lanes|purposes|keyHash[,nextKeyHash];.... Lanes are attended, workload and + /// receipt; purposes (workload lane only) must be in ; 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: + /// api=attended,workload,receipt|records-export,invoicing|3f...;workers=workload|neris-submission|9a...;backoffice=receipt||c1.... + /// An invalid map stops the broker at startup. + /// + public static string BrokerClientCredentials = ""; + + /// + /// Broker side: accept the legacy during the migration window (plan section 8.5 + /// rule 6). Turn off, and remove the key, once the broker logs show no legacy use. + /// + public static bool BrokerLegacySharedKeyEnabled = true; + + /// Client side: this host's broker credential id (X-Resgrid-Broker-Client), e.g. api or workers. + public static string BrokerClientId = ""; + + /// Client side: this host's broker credential key, supplied through the environment only. + public static string BrokerClientKey = ""; + /// Maximum field items one broker request may carry; larger requests are refused. public static int BrokerMaxItemsPerRequest = 200; /// /// 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 & 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 () narrows this list further. /// public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting,protected-workflow"; @@ -133,6 +157,37 @@ public static class DataProtectionConfig /// Bounded clock skew allowed when validating grant lifetimes, in seconds. public static int GrantClockSkewSeconds = 30; + /// + /// 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. + /// + public static string SessionAssertionSigningCertificatePath = ""; + + /// PFX password for the session-assertion signing certificate, supplied through the environment only. + public static string SessionAssertionSigningCertificatePassword = ""; + + /// + /// 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). + /// + public static string SessionAssertionValidationCertificatePath = ""; + + /// Issuer (iss) on broker session assertions; the audience is . + public static string SessionAssertionIssuer = "resgrid-identity-session"; + + /// Session-assertion lifetime in seconds (the broker applies as skew). + public static int SessionAssertionLifetimeSeconds = 60; + + /// + /// 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. + /// + public static bool BrokerRequireSessionAssertion = false; + + /// How long broker request ids and session-assertion ids stay recorded as used, in minutes. + public static int BrokerReplayWindowMinutes = 15; + /// /// Key-wrapping provider the broker uses: "OpenBaoTransit" (production default), or "LocalDev" /// for synthetic/non-PHI testing only — production startup must reject LocalDev. diff --git a/Core/Resgrid.Config/PasskeyConfig.cs b/Core/Resgrid.Config/PasskeyConfig.cs new file mode 100644 index 000000000..f7a3b37d7 --- /dev/null +++ b/Core/Resgrid.Config/PasskeyConfig.cs @@ -0,0 +1,98 @@ +namespace Resgrid.Config +{ + /// + /// 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. + /// + public static class PasskeyConfig + { + // ── Rollout gates (all OFF) ─────────────────────────────────────────────────── + + /// Allow users to register passkeys. + public static bool RegistrationEnabled = false; + + /// Accept passkeys as login MFA. + public static bool LoginAcceptanceEnabled = false; + + /// Accept passkey evidence for Protected Data Grants. + public static bool AdpAcceptanceEnabled = false; + + /// Issue version 2 Protected Data Grants. Every reader must support v2 before this is turned on. + public static bool EmitGrantV2 = false; + + /// Allow shared-device (vehicle tablet / dispatch workstation) sessions. + public static bool SharedDeviceModeEnabled = false; + + /// Allow "Approve with Responder" cross-app MFA. + public static bool ResponderApprovalEnabled = false; + + /// Allow provider step-up (federated MFA) for departments that opt in. + public static bool ProviderStepUpEnabled = false; + + // ── Relying parties (one per client; workbook section 5) ────────────────────── + + /// + /// One relying party per client, separated by ";". Each entry is client=rpId|origin,origin, where client is + /// web, responder, unit, dispatch or command (ic), the RP ID is that client's own host, and each origin is + /// https://host[:port] under the RP ID or android:apk-key-hash:<base64url>. Example: + /// web=app.resgrid.com|https://app.resgrid.com;unit=unit.resgrid.com|https://unit.resgrid.com,android:apk-key-hash:abc. + /// Empty means passkeys are unavailable on this deployment. + /// + public static string RelyingParties = ""; + + /// Name the platform shows in the passkey prompt. + public static string RelyingPartyName = "Resgrid"; + + // ── Ceremony limits ─────────────────────────────────────────────────────────── + + public static int RegistrationChallengeLifetimeSeconds = 300; + + public static int AssertionChallengeLifetimeSeconds = 120; + + /// Failed verifications allowed against one challenge before it is spent. + public static int ChallengeMaxAttempts = 5; + + /// Pending challenges one user may hold at once; more is refused rather than queued. + public static int MaxOutstandingChallengesPerUser = 10; + + /// Active passkeys per user per client (at most 5 clients). + public static int MaxActiveCredentialsPerClient = 10; + + public static int MaxDisplayNameLength = 100; + + // ── Responder approval (plan section 7.9 abuse controls) ────────────────────── + + /// An approval request lives this long and is never extended. + public static int ApprovalRequestLifetimeSeconds = 120; + + /// Wrong numbers allowed before the request is denied. + public static int ApprovalMaxNumberAttempts = 3; + + /// Approval requests one user may create per . + public static int ApprovalMaxRequestsPerWindow = 5; + + public static int ApprovalRateWindowMinutes = 15; + + /// After two denials or expiries in a row (or one "not me"), new requests are refused this long. + public static int ApprovalSuspensionMinutes = 15; + + /// How long after its expiry an approved request can still be used by the requester's final poll. + public static int ApprovalConsumeGraceSeconds = 30; + + // ── Shared vehicle and workstation sessions (plan sections 10.5 and 12.5) ───── + + /// The longest idle lock a department may choose (at most 15 minutes). + public static int SharedMaxIdleLockMinutes = 15; + + /// The longest shift a department may choose (at most 24 hours). + public static int SharedMaxShiftHours = 24; + + /// Operator activity is written at most this often per session, so a busy screen is not a write per request. + public static int SharedActivityWriteIntervalSeconds = 30; + + /// Unlock attempts one shared session may make per 5 minutes, on top of the account lockout. + public static int SharedUnlockMaxAttempts = 5; + } +} diff --git a/Core/Resgrid.Config/SearchConfig.cs b/Core/Resgrid.Config/SearchConfig.cs index f3804a476..e1213bf5a 100644 --- a/Core/Resgrid.Config/SearchConfig.cs +++ b/Core/Resgrid.Config/SearchConfig.cs @@ -19,6 +19,12 @@ public static class SearchConfig /// Hard limit on hits a single query may return. public static int MaxResults = 200; + /// + /// Deepest window the search page and its CSV export may authorize for one narrowed query (a single family, a date + /// range), so a "how many calls mention X" question gets a complete list and an exact count. Typeahead stays on MaxResults. + /// + public static int MaxPageWindow = 1000; + /// IndexWriter RAM buffer before a flush. public static int RamBufferSizeMb = 16; diff --git a/Core/Resgrid.Config/SessionSecurityConfig.cs b/Core/Resgrid.Config/SessionSecurityConfig.cs index 1eba5f571..31d12b3b4 100644 --- a/Core/Resgrid.Config/SessionSecurityConfig.cs +++ b/Core/Resgrid.Config/SessionSecurityConfig.cs @@ -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; } } diff --git a/Core/Resgrid.Config/SsoConfig.cs b/Core/Resgrid.Config/SsoConfig.cs index 1afebab51..7b5213d0d 100644 --- a/Core/Resgrid.Config/SsoConfig.cs +++ b/Core/Resgrid.Config/SsoConfig.cs @@ -59,12 +59,66 @@ public static class SsoConfig /// public static string SamlAcsPath = "/api/v4/connect/saml-mobile-callback"; + /// + /// 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 SamlLoginUrl. + /// Example result: https://api.resgrid.com/api/v4/connect/saml-mobile-login + /// + public static string SamlLoginPath = "/api/v4/connect/saml-mobile-login"; + /// /// Relative URL path segment used to construct SAML SP Entity IDs. /// Example result: https://api.resgrid.com/saml/{configId} /// public static string SamlEntityIdBasePath = "/saml/"; + // ── Server-brokered SSO (passkey plan section 7.7.2; workbook section 7.3) ── + + /// + /// Rollout gate for server-brokered SSO (Sso/Begin, the OIDC callback and brokered SAML, Sso/Redeem). + /// Off: those endpoints refuse, and the legacy client-run OIDC flow, SAML relay and external-token are unchanged. + /// + public static bool BrokeredSsoEnabled = false; + + /// + /// Relative path of the OIDC redirect URI every department registers with its IdP for brokered SSO, appended to + /// . Example result: https://api.resgrid.com/api/v4/connect/oidc-callback + /// + public static string OidcCallbackPath = "/api/v4/connect/oidc-callback"; + + /// + /// The deployment's return-target registry: where the server may send the one-time sso_code, per client, + /// matched exactly. Entries are client=target,target 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 http://127.0.0.1:*/path (any port). Example: + /// web=https://app.resgrid.com/Account/SsoReturn;unit=resgridunit://sso-return,https://unit.resgrid.com/sso-return,http://127.0.0.1:*/sso-return. + /// Empty means brokered SSO has nowhere to return and is unavailable. + /// + public static string BrokeredReturnTargets = ""; + + /// + /// 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: /auth/callback, or /login/sso for Dispatch). + /// Entries are client=origin separated by ";", where client is responder, unit, dispatch or ic and origin is + /// https://host[:port] (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 + /// responder=https://responder.resgrid.com;unit=https://unit.resgrid.com;dispatch=https://dispatch.resgrid.com; + /// a region lists its own hosts once it serves web editions. Only shown to admins: nothing is redirected by it. + /// + public static string AppWebOrigins = "responder=https://responder.resgrid.local;unit=https://unit.resgrid.local;dispatch=https://dispatch.resgrid.local"; + + /// How long a brokered SSO transaction waits for the IdP. Non-sliding. + public static int BrokeredTransactionLifetimeSeconds = 600; + + /// How long the one-time sso_code can be redeemed. + public static int BrokeredCodeLifetimeSeconds = 60; + + /// + /// For SSO reauthentication: the most time the IdP's own sign-in (auth_time or AuthnInstant) may + /// predate the callback. Sent as OIDC max_age; SAML sends ForceAuthn. + /// + public static int ReauthenticationMaxAgeSeconds = 300; + // ── Feature flags ───────────────────────────────────────────────────── /// diff --git a/Core/Resgrid.Config/TwoFactorConfig.cs b/Core/Resgrid.Config/TwoFactorConfig.cs index c06b75b26..5f259020f 100644 --- a/Core/Resgrid.Config/TwoFactorConfig.cs +++ b/Core/Resgrid.Config/TwoFactorConfig.cs @@ -26,6 +26,87 @@ public static class TwoFactorConfig /// public static int StepUpVerificationWindowMinutes = 15; + // ── First-Factor Reauthentication (passkey plan section 6.2) ───────────────────── + + /// + /// A password (or SSO) sign-in or reauthentication this recent may START a credential change: set up, replace or + /// turn off the authenticator. Older sessions are sent to reauthenticate first. + /// + public static int FirstFactorReauthWindowMinutes = 5; + + /// + /// A credential change started inside the reauthentication window may be COMPLETED this long after the + /// reauthentication, so scanning a QR code or finding the app does not force a second password prompt. + /// + public static int FirstFactorOperationWindowMinutes = 10; + + /// How long a staged (not yet verified) authenticator key stays usable. + public static int StagedAuthenticatorLifetimeMinutes = 10; + + /// Server-side MFA evidence rows are purged this long after they were recorded. + public static int MfaEvidenceRetentionHours = 24; + + // ── Department MFA policy (passkey plan section 7.6) ────────────────────────────── + + /// + /// Rollout gate for enforcing DepartmentSecurityPolicy.RequireMfa on the paths that never enforced it: + /// Web password login, the API password grant and Web department entry (section 7.6 rows 1, 3 and 5). The API + /// SSO exchange already enforces it and is unaffected. Off until administrators have been notified and the + /// affected members counted (section 7.6 rollout). + /// + public static bool RequireMfaEnforcementEnabled = false; + + /// + /// How recent actual MFA must be for security, SSO and MFA policy changes and ADP management commands (section + /// 7.6 rows 10 and 13, section 8.4). A department's longer ADP data window never stretches this. + /// + public static int SensitiveOperationWindowMinutes = 5; + + // ── Login MFA transaction (passkey plan sections 5.2 and 7.2; workbook section 7.1) ── + + /// + /// Rollout gate for mfa_flow=transaction on the API password grant. Off: a client asking for the transaction + /// gets today's legacy mfa_required response unchanged and falls back to totp_code. + /// + public static bool LoginMfaTransactionEnabled = false; + + /// + /// Rollout gate for Web sign-in on the login transaction (passkey plan section 7.1): after the password, Core Web offers + /// every second factor the account has and the department accepts (authenticator code, a passkey for the web, approval + /// from Responder, a recovery code). It also needs . Off: Web keeps the + /// authenticator-code page unchanged. + /// + public static bool WebLoginMfaTransactionEnabled = false; + + /// How long a partial login waits for its second factor. Non-sliding. + public static int LoginMfaTransactionLifetimeSeconds = 300; + + /// Failed second-factor attempts, of any method, before the transaction is spent. + public static int LoginMfaTransactionMaxAttempts = 5; + + /// How long the one-use completion code can be redeemed at the token endpoint. + public static int LoginMfaCompletionCodeLifetimeSeconds = 60; + + // ── Factor recovery and security notices (passkey plan sections 5.4 and 6.4) ───── + + /// How long a restricted factor recovery lasts. Non-sliding; expired recovery starts with the first factor again. + public static int FactorRecoveryLifetimeMinutes = 10; + + /// Wrong codes for the replacement authenticator before the recovery is spent. + public static int FactorRecoveryMaxAttempts = 5; + + /// + /// Send security notices (passkey plan section 6.4). Off: nothing is queued or sent, so deploying changes nothing for + /// existing accounts until outbound email is confirmed and this is turned on. + /// + public static bool SecurityNoticesEnabled = false; + + /// Delivery attempts for one security notice before it is recorded as failed. + public static int SecurityNoticeMaxAttempts = 8; + + /// How long sent and failed security notices are kept. + public static int SecurityNoticeRetentionDays = 90; + // ── TOTP Settings ───────────────────────────────────────────────────────────── /// @@ -34,6 +115,33 @@ public static class TwoFactorConfig /// Microsoft Authenticator, Authy). /// public static string TotpIssuerName = "Resgrid"; + + // ── Authenticator seeds at rest (passkey workbook section 12, slice 14) ──────── + + /// + /// Write authenticator seeds (the active key and a staged replacement) encrypted, and re-encrypt older ones as they + /// are read. Every build that has this field reads both forms, so deploy it everywhere before turning this on. Turn it + /// off and run --AuthenticatorSeeds --Decrypt before rolling back below this build. + /// + public static bool AuthenticatorSeedEncryptionEnabled = false; + + /// + /// Seed encryption keys, separated by ";": keyId=base64 of 32 random bytes; key ids are letters and digits. + /// Empty means one key derived from SecurityConfig.EncryptionKey, id m1. To rotate, add a key, make it + /// active, run --AuthenticatorSeeds --Encrypt, then remove the old key. + /// + public static string AuthenticatorSeedKeyRing = ""; + + /// The key id new seeds are written with. Empty means m1 when the ring is empty. + public static string AuthenticatorSeedActiveKeyId = ""; + + // ── Recent MFA activity (plan section 6.5) ───────────────────────────────────── + + /// How long each verification is kept for the account's recent-activity view. + public static int MfaActivityRetentionDays = 30; + + /// Failed verifications recorded per account per hour; more are counted by the lockout but not stored. + public static int MfaActivityDeniedPerHour = 20; } } diff --git a/Core/Resgrid.Localization/Account/Login.ar.resx b/Core/Resgrid.Localization/Account/Login.ar.resx index c110397cf..b35323a0c 100644 --- a/Core/Resgrid.Localization/Account/Login.ar.resx +++ b/Core/Resgrid.Localization/Account/Login.ar.resx @@ -207,4 +207,7 @@ Affiliate Code + + المنطقة + diff --git a/Core/Resgrid.Localization/Account/Login.de.resx b/Core/Resgrid.Localization/Account/Login.de.resx index 6871c925d..b1bb3b7bc 100644 --- a/Core/Resgrid.Localization/Account/Login.de.resx +++ b/Core/Resgrid.Localization/Account/Login.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Einladung abgeschlossen @@ -212,4 +203,7 @@ Affiliate Code + + Region + diff --git a/Core/Resgrid.Localization/Account/Login.es.resx b/Core/Resgrid.Localization/Account/Login.es.resx index 25fa8e525..80cb941f1 100644 --- a/Core/Resgrid.Localization/Account/Login.es.resx +++ b/Core/Resgrid.Localization/Account/Login.es.resx @@ -261,4 +261,7 @@ Affiliate Code + + Región + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Account/Login.fr.resx b/Core/Resgrid.Localization/Account/Login.fr.resx index aaeaeb57e..70fb4ca9a 100644 --- a/Core/Resgrid.Localization/Account/Login.fr.resx +++ b/Core/Resgrid.Localization/Account/Login.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Invitation complétée @@ -212,4 +203,7 @@ Affiliate Code + + Région + diff --git a/Core/Resgrid.Localization/Account/Login.it.resx b/Core/Resgrid.Localization/Account/Login.it.resx index 6e7a384c8..e17dc0508 100644 --- a/Core/Resgrid.Localization/Account/Login.it.resx +++ b/Core/Resgrid.Localization/Account/Login.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Invito completato @@ -212,4 +203,7 @@ Affiliate Code + + Regione + diff --git a/Core/Resgrid.Localization/Account/Login.pl.resx b/Core/Resgrid.Localization/Account/Login.pl.resx index 09686d04e..2cb71c0e1 100644 --- a/Core/Resgrid.Localization/Account/Login.pl.resx +++ b/Core/Resgrid.Localization/Account/Login.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Zaproszenie ukończone @@ -212,4 +203,7 @@ Affiliate Code + + Region + diff --git a/Core/Resgrid.Localization/Account/Login.sv.resx b/Core/Resgrid.Localization/Account/Login.sv.resx index d6d1af284..3b23082ea 100644 --- a/Core/Resgrid.Localization/Account/Login.sv.resx +++ b/Core/Resgrid.Localization/Account/Login.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Inbjudan slutförd @@ -212,4 +203,7 @@ Affiliate Code + + Region + diff --git a/Core/Resgrid.Localization/Account/Login.uk.resx b/Core/Resgrid.Localization/Account/Login.uk.resx index 0751f914c..783c22198 100644 --- a/Core/Resgrid.Localization/Account/Login.uk.resx +++ b/Core/Resgrid.Localization/Account/Login.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Запрошення завершено @@ -212,4 +203,7 @@ Affiliate Code + + Регіон + diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx index c48907c6a..89be58df4 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx index f1e14e2da..3d239ab21 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.es.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Marque la casilla de abajo para confirmar que comprende lo anterior y desea eliminar su cuenta. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx index 39e40b83f..432f5ec0d 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx index 24bd2b0e8..6bd73e7c5 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx index e1d04e03f..de5faeb84 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx index be90c45fe..cd85d17ae 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx index d3a03e0bc..a834346f6 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/DeleteAccount.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Check the checkbox below to confirm you understand the above and wish to delete you account. diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.ar.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.ar.resx index 789833bf7..4987f961e 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.ar.resx @@ -18,4 +18,5 @@ كلمة المرور الجديدة تأكيد كلمة المرور الجديدة تغيير كلمة المرور والمتابعة + سيؤدي تغيير كلمة المرور إلى تسجيل خروجك من جميع جلسات Resgrid وإلغاء جميع رموز الوصول ورموز التحديث. diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.de.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.de.resx index 2bf4f60be..ed9cae43b 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.de.resx @@ -42,4 +42,5 @@ Neues Passwort Neues Passwort bestätigen Passwort ändern & fortfahren + Wenn Sie Ihr Passwort ändern, werden Sie von allen Resgrid-Sitzungen abgemeldet, und alle Zugriffs- und Aktualisierungstoken werden widerrufen. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.el.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.el.resx index 9f8877c3b..44849bfe2 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.el.resx @@ -42,4 +42,5 @@ Νέος Κωδικός Πρόσβασης Επιβεβαίωση Νέου Κωδικού Πρόσβασης Αλλαγή Κωδικού Πρόσβασης και Συνέχεια + Η αλλαγή του κωδικού πρόσβασής σας θα σας αποσυνδέσει από όλες τις συνεδρίες Resgrid και θα ανακαλέσει όλα τα διακριτικά πρόσβασης και ανανέωσης. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.es.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.es.resx index a41b43987..d807e4ae5 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.es.resx @@ -42,4 +42,5 @@ Nueva contraseña Confirmar nueva contraseña Cambiar contraseña & continuar + Al cambiar su contraseña, se cerrarán todas sus sesiones de Resgrid y se revocarán todos los tokens de acceso y de actualización. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.fr.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.fr.resx index 120483a9f..8279e5043 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.fr.resx @@ -42,4 +42,5 @@ Nouveau mot de passe Confirmer le nouveau mot de passe Changer le mot de passe & continuer + La modification de votre mot de passe vous déconnectera de toutes vos sessions Resgrid et révoquera tous les jetons d'accès et d'actualisation. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.it.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.it.resx index ea4dcede7..d9750550e 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.it.resx @@ -42,4 +42,5 @@ Nuova password Conferma nuova password Cambia password & continua + La modifica della password ti disconnetterà da tutte le sessioni Resgrid e revocherà tutti i token di accesso e di aggiornamento. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.pl.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.pl.resx index 1af04993c..ab1d2a8b1 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.pl.resx @@ -42,4 +42,5 @@ Nowe hasło Potwierdź nowe hasło Zmień hasło & kontynuuj + Zmiana hasła wyloguje Cię ze wszystkich sesji Resgrid i unieważni wszystkie tokeny dostępu i odświeżania. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.sv.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.sv.resx index 2058dc483..48feb9bae 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.sv.resx @@ -42,4 +42,5 @@ Nytt lösenord Bekräfta nytt lösenord Byt lösenord & fortsätt + När du byter lösenord loggas du ut från alla Resgrid-sessioner och alla åtkomst- och uppdateringstoken återkallas. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.uk.resx b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.uk.resx index 17075ad9e..cd696a97e 100644 --- a/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Account/ForcePasswordChange.uk.resx @@ -42,4 +42,5 @@ Новий пароль Підтвердіть новий пароль Змінити пароль & продовжити + Зміна пароля завершить усі ваші сеанси Resgrid і відкличе всі токени доступу та оновлення. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx index 48493dca7..311ab4390 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx @@ -5190,6 +5190,27 @@ إشعار القسم / المستخدمون المراد إشعارهم + + سياسة أمان القسم / قبول المصادقة الثنائية الحديثة عند تسجيل الدخول لـ ADP + + + سياسة أمان القسم / قبول المصادقة الثنائية الحديثة عند إلغاء القفل لـ ADP + + + سياسة أمان القسم / السماح بالمصادقة الثنائية الموحدة لـ ADP + + + سياسة أمان القسم / السماح بالمصادقة الثنائية الموحدة لتسجيل الدخول + + + سياسة أمان القسم / السماح بمفاتيح المرور لـ ADP + + + سياسة أمان القسم / السماح بمفاتيح المرور لمصادقة تسجيل الدخول الثنائية + + + سياسة أمان القسم / السماح بالموافقة عبر Responder + سياسة أمان القسم / نطاقات IP المسموح بها @@ -5217,6 +5238,15 @@ سياسة أمان القسم / مهلة الجلسة بالدقائق + + سياسة أمان القسم / دقائق الخمول قبل قفل الجلسة المشتركة + + + سياسة أمان القسم / التطبيقات التي تتطلب الوضع المشترك + + + سياسة أمان القسم / ساعات المناوبة للجلسة المشتركة + إعداد تسجيل الدخول الموحد (SSO) للقسم / السماح بتسجيل الدخول المحلي @@ -5253,6 +5283,12 @@ إعداد تسجيل الدخول الموحد (SSO) للقسم / معرّف الكيان + + إعداد تسجيل الدخول الموحد (SSO) للقسم / تعيين المصادقة متعددة العوامل الاتحادية بصيغة JSON + + + إعداد تسجيل الدخول الموحد (SSO) للقسم / رابط تسجيل الدخول الموحد لدى موفر الهوية + إعداد تسجيل الدخول الموحد (SSO) للقسم / مفعّل @@ -5427,6 +5463,27 @@ مستلمون صريحون من الأعضاء. تُقيَّم العضوية وأهلية القناة بشكل منفصل عند وقت الإرسال. + + يسمح للمصادقة الثنائية المكتملة عند تسجيل الدخول في الجلسة نفسها بفتح البيانات المحمية ضمن نافذة التحقق المعزز، للطرق التي تسمح بها إعدادات البيانات المحمية. يؤدي التغيير إلى إنهاء الوصول الحالي إلى البيانات المحمية. للعضو المسؤول فقط. + + + يسمح للتحقق الحديث من إلغاء القفل الذي أجراه المشغل على جهاز مشترك بفتح البيانات المحمية. يؤدي التغيير إلى إنهاء الوصول الحالي إلى البيانات المحمية. للعضو المسؤول فقط. + + + يقبل المصادقة الثنائية لموفر الهوية للبيانات المحمية. يتطلب ربطًا مختبرًا للمصادقة الثنائية؛ ويؤدي التغيير إلى إنهاء الوصول الحالي إلى البيانات المحمية. للعضو المسؤول فقط. + + + يقبل المصادقة الثنائية لموفر الهوية لتسجيل الدخول والتحقق المعزز. يتطلب ربطًا مختبرًا للمصادقة الثنائية في إعداد SSO؛ وبدونه لا تُحتسب مطالبات الموفر أبدًا. للعضو المسؤول فقط. + + + يقبل مفتاح المرور للوصول إلى البيانات المحمية وإدارة ADP والموافقة على مسارات العمل المحمية. تُقبل رموز تطبيق المصادقة دائمًا. يؤدي التغيير إلى إنهاء الوصول الحالي إلى البيانات المحمية، فيتحقق الأعضاء مرة أخرى. للعضو المسؤول فقط. + + + يقبل مفتاح مرور مسجلًا في التطبيق الطالب كمصادقة ثنائية لتسجيل الدخول وتغيير القسم والتحقق المعزز. تُقبل رموز تطبيق المصادقة دائمًا. يرفع التغيير إصدار سياسة المصادقة الثنائية؛ والجلسات التي لم يعد عاملها الثاني الوحيد مسموحًا به تتحقق مرة أخرى. للعضو المسؤول فقط. + + + يقبل الموافقة باستخدام مفتاح المرور في تطبيق Responder الخاص بالعضو حيث يكون إعداد مفتاح المرور المقابل مفعّلًا؛ ولا يُستخدم أبدًا لتغييرات الأمان أو طرق تسجيل الدخول إلى الحساب. يؤدي التغيير أيضًا إلى إنهاء الوصول الحالي إلى البيانات المحمية. للعضو المسؤول فقط. + نطاقات الشبكة المسموح بها لتسجيل الدخول. الحقل الفارغ لا يضيف أي قيد على النطاق. راجع شبكات المستجيبين الشرعية ووصول الاسترداد قبل تضييقه. @@ -5454,6 +5511,15 @@ مهلة الخمول بالدقائق؛ الصفر يترك السلوك لإعداد المضيف. تحقق من الجلسات التي تديرها السياسة ومن كل عميل مدعوم بدلًا من افتراض تسجيل خروج شامل فوري. + + عدد الدقائق دون نشاط من المشغل قبل قفل جلسة جهاز لوحي في مركبة أو محطة عمل مشتركة، من 1 إلى 15. التحديثات في الخلفية والتنبيهات الواردة لا تُعد نشاطًا. تسري القيمة الأكثر صرامة على الجلسات الجارية عند طلبها التالي. للعضو المسؤول فقط. + + + جلسات Unit وIC وDispatch التي يشغّلها هذا القسم دائمًا كجلسات مشتركة، أيًا كان إعداد التثبيت؛ وتُحتسب أيضًا عمليات تسجيل الدخول التي لا تذكر تطبيقها. يتطلب إصدارات تطبيقات تدعم الوضع المشترك. للعضو المسؤول فقط. + + + عدد الساعات بعد تسجيل الدخول التي تنتهي عندها الجلسة المشتركة أيًا كان النشاط، من 1 إلى 24. القيمة الأقصر تنهي الجلسات الجارية في وقت أبكر؛ والقيمة الأطول لا تمددها أبدًا. للعضو المسؤول فقط. + يسمح بمسار كلمة مرور محلية إلى جانب هذا المزوّد حيثما تسمح السياسة. راجع متطلب تسجيل الدخول الموحد على مستوى القسم وخطة الاسترداد معًا. @@ -5490,6 +5556,12 @@ معرّف مزوّد خدمة SAML المسجَّل لدى مزوّد الهوية. يجب أن يتطابق مع التكامل المُعدّ. + + نتائج تسجيل الدخول لدى المزوّد التي تُحتسب مصادقةً متعددة العوامل للتحقق الإضافي عبر المزوّد. أي تغيير في التعيين أو الجهة المُصدِرة أو العميل يتطلب اختبارًا ناجحًا جديدًا قبل قبول المصادقة متعددة العوامل من المزوّد مرة أخرى. + + + عنوان تسجيل الدخول لدى مزوّد SAML الذي يرسل إليه تسجيل الدخول الذي يبدأ من Resgrid طلبه. تحقّق منه مقابل البيانات الوصفية للمزوّد قبل الاعتماد عليه. + يجعل إعداد هذا المزوّد متاحًا لمسار تسجيل الدخول الموحد المدعوم. علامة التفعيل المحفوظة ليست اختبار تسجيل دخول ناجحًا. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx index 71ed7f3e0..a0995ed63 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx @@ -5190,6 +5190,27 @@ Abteilungsbenachrichtigung / Zu benachrichtigende Mitglieder + + Sicherheitsrichtlinie der Abteilung / Aktuelle Anmelde-MFA für ADP akzeptieren + + + Sicherheitsrichtlinie der Abteilung / Aktuelle Entsperr-MFA für ADP akzeptieren + + + Sicherheitsrichtlinie der Abteilung / Föderierte MFA für ADP zulassen + + + Sicherheitsrichtlinie der Abteilung / Föderierte MFA für Anmelde-MFA zulassen + + + Sicherheitsrichtlinie der Abteilung / Passkeys für ADP zulassen + + + Sicherheitsrichtlinie der Abteilung / Passkeys für Anmelde-MFA zulassen + + + Sicherheitsrichtlinie der Abteilung / Responder-Freigabe zulassen + Sicherheitsrichtlinie der Abteilung / Zulässige IP-Bereiche @@ -5217,6 +5238,15 @@ Sicherheitsrichtlinie der Abteilung / Sitzungs-Timeout (Minuten) + + Sicherheitsrichtlinie der Abteilung / Minuten Inaktivität bis zur Sperre gemeinsam genutzter Sitzungen + + + Sicherheitsrichtlinie der Abteilung / Apps mit erzwungenem gemeinsamem Modus + + + Sicherheitsrichtlinie der Abteilung / Schichtdauer gemeinsam genutzter Sitzungen (Stunden) + SSO-Konfiguration der Abteilung / Lokale Anmeldung zulassen @@ -5253,6 +5283,12 @@ SSO-Konfiguration der Abteilung / Entitäts-ID + + SSO-Konfiguration der Abteilung / Zuordnung für föderierte MFA (JSON) + + + SSO-Konfiguration der Abteilung / SSO-URL des IdP + SSO-Konfiguration der Abteilung / Aktiviert @@ -5427,6 +5463,27 @@ Ausdrückliche Mitgliederempfänger. Mitgliedschaft und Kanalberechtigung werden zum Sendezeitpunkt gesondert bewertet. + + Erlaubt, dass die bei der Anmeldung in derselben Sitzung abgeschlossene MFA geschützte Daten innerhalb des Step-up-Zeitfensters öffnet, für Methoden, die die Einstellungen für geschützte Daten zulassen. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. Nur für das verwaltende Mitglied. + + + Erlaubt, dass die frische Entsperrbestätigung der bedienenden Person auf einem gemeinsam genutzten Gerät geschützte Daten öffnet. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. Nur für das verwaltende Mitglied. + + + Akzeptiert die MFA des Identitätsanbieters für geschützte Daten. Erfordert eine getestete MFA-Zuordnung; eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. Nur für das verwaltende Mitglied. + + + Akzeptiert die MFA des Identitätsanbieters für Anmeldung und Step-up. Erfordert eine getestete MFA-Zuordnung in der SSO-Konfiguration; ohne sie zählen die Angaben des Anbieters nie. Nur für das verwaltende Mitglied. + + + Akzeptiert einen Passkey für den Zugriff auf geschützte Daten, die ADP-Verwaltung und Freigaben geschützter Workflows. Authenticator-Codes werden immer akzeptiert. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten, sodass Mitglieder sich erneut bestätigen. Nur für das verwaltende Mitglied. + + + Akzeptiert einen in der anfragenden App registrierten Passkey als MFA für Anmeldung, Abteilungswechsel und Step-up. Authenticator-Codes werden immer akzeptiert. Eine Änderung erhöht die MFA-Richtlinienversion; Sitzungen, deren einziger zweiter Faktor nun nicht mehr zulässig ist, bestätigen sich erneut. Nur für das verwaltende Mitglied. + + + Akzeptiert die Freigabe mit dem Passkey der Responder-App des Mitglieds, wo die passende Passkey-Einstellung aktiv ist; nie für Sicherheitsänderungen oder Anmeldemethoden des Kontos. Eine Änderung beendet auch den aktuellen Zugriff auf geschützte Daten. Nur für das verwaltende Mitglied. + Zulässige Netzwerkbereiche für die Anmeldung. Leer bedeutet keine Bereichseinschränkung. Prüfen Sie legitime Einsatzkräfte-Netzwerke und den Wiederherstellungszugriff, bevor Sie dies einschränken. @@ -5454,6 +5511,15 @@ Leerlauf-Timeout in Minuten; null überlässt dies dem Host-Verhalten. Verifizieren Sie richtliniengesteuerte Sitzungen und jeden unterstützten Client, statt eine sofortige globale Abmeldung anzunehmen. + + Minuten ohne Aktivität der bedienenden Person, bevor die Sitzung eines gemeinsam genutzten Fahrzeugtablets oder Arbeitsplatzes gesperrt wird, von 1 bis 15. Hintergrundaktualisierungen und eingehende Alarme gelten nicht als Aktivität. Ein strengerer Wert gilt für laufende Sitzungen bei ihrer nächsten Anfrage. Nur für das verwaltende Mitglied. + + + Unit-, IC- und Dispatch-Sitzungen, die diese Abteilung immer als gemeinsam genutzte Sitzungen führt, unabhängig von der Einstellung der Installation; Anmeldungen, die ihre App nicht nennen, zählen ebenfalls. Erfordert App-Versionen, die den gemeinsamen Modus unterstützen. Nur für das verwaltende Mitglied. + + + Stunden nach der Anmeldung, nach denen eine gemeinsam genutzte Sitzung unabhängig von der Aktivität endet, von 1 bis 24. Ein kürzerer Wert beendet laufende Sitzungen früher; ein längerer verlängert sie nie. Nur für das verwaltende Mitglied. + Erlaubt einen lokalen Passwortweg neben diesem Anbieter, sofern die Richtlinie dies zulässt. Prüfen Sie die abteilungsweite SSO-Anforderung und den Wiederherstellungsplan gemeinsam. @@ -5490,6 +5556,12 @@ Beim Identitätsanbieter registrierte SAML-Dienstanbieter-Kennung. Sie muss mit der konfigurierten Integration übereinstimmen. + + Welche Anmeldeergebnisse des Anbieters bei der Anbieter-Step-up-Prüfung als MFA gelten. Nach einer Änderung der Zuordnung, des Ausstellers oder des Clients ist ein neuer erfolgreicher Test nötig, bevor Anbieter-MFA wieder akzeptiert wird. + + + SAML-Anmeldeadresse des Anbieters, an die eine in Resgrid gestartete Anmeldung ihre Anfrage sendet. Prüfen Sie sie anhand der Anbietermetadaten, bevor Sie sich darauf verlassen. + Macht diese Anbieterkonfiguration für den unterstützten SSO-Ablauf verfügbar. Ein gespeichertes Aktivierungskennzeichen ist kein erfolgreicher Anmeldetest. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx index 7e1d602f5..a7a622933 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx @@ -5190,6 +5190,27 @@ Ειδοποίηση Τμήματος / Χρήστες προς Ειδοποίηση + + Πολιτική Ασφαλείας Τμήματος / Αποδοχή πρόσφατου MFA σύνδεσης για ADP + + + Πολιτική Ασφαλείας Τμήματος / Αποδοχή πρόσφατου MFA ξεκλειδώματος για ADP + + + Πολιτική Ασφαλείας Τμήματος / Επιτρέπεται ομοσπονδιακό MFA για ADP + + + Πολιτική Ασφαλείας Τμήματος / Επιτρέπεται ομοσπονδιακό MFA για MFA σύνδεσης + + + Πολιτική Ασφαλείας Τμήματος / Επιτρέπονται κλειδιά πρόσβασης για ADP + + + Πολιτική Ασφαλείας Τμήματος / Επιτρέπονται κλειδιά πρόσβασης για MFA σύνδεσης + + + Πολιτική Ασφαλείας Τμήματος / Επιτρέπεται έγκριση από το Responder + Πολιτική Ασφαλείας Τμήματος / Επιτρεπόμενα Εύρη IP @@ -5217,6 +5238,15 @@ Πολιτική Ασφαλείας Τμήματος / Λεπτά Λήξης Χρόνου Συνεδρίας + + Πολιτική Ασφαλείας Τμήματος / Λεπτά αδράνειας πριν κλειδωθεί κοινόχρηστη συνεδρία + + + Πολιτική Ασφαλείας Τμήματος / Εφαρμογές με υποχρεωτική κοινόχρηστη λειτουργία + + + Πολιτική Ασφαλείας Τμήματος / Ώρες βάρδιας κοινόχρηστης συνεδρίας + Ρύθμιση SSO Τμήματος / Αποδοχή Τοπικής Σύνδεσης @@ -5253,6 +5283,12 @@ Ρύθμιση SSO Τμήματος / Αναγνωριστικό Οντότητας + + Ρύθμιση SSO Τμήματος / JSON Αντιστοίχισης Ομοσπονδιακού MFA + + + Ρύθμιση SSO Τμήματος / URL SSO Παρόχου Ταυτότητας + Ρύθμιση SSO Τμήματος / Ενεργοποιημένο @@ -5427,6 +5463,27 @@ Ρητοί παραλήπτες μελών. Η συμμετοχή και η επιλεξιμότητα καναλιού αξιολογούνται ξεχωριστά κατά την αποστολή. + + Επιτρέπει στο MFA που ολοκληρώθηκε κατά τη σύνδεση στην ίδια συνεδρία να ανοίγει προστατευμένα δεδομένα μέσα στο παράθυρο ενισχυμένης επαλήθευσης, για μεθόδους που επιτρέπουν οι ρυθμίσεις προστατευμένων δεδομένων. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. Μόνο το διαχειριστικό μέλος. + + + Επιτρέπει στην πρόσφατη επαλήθευση ξεκλειδώματος του χειριστή σε κοινόχρηστη συσκευή να ανοίγει προστατευμένα δεδομένα. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. Μόνο το διαχειριστικό μέλος. + + + Δέχεται το MFA του παρόχου ταυτότητας για προστατευμένα δεδομένα. Απαιτεί δοκιμασμένη αντιστοίχιση MFA· η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. Μόνο το διαχειριστικό μέλος. + + + Δέχεται το MFA του παρόχου ταυτότητας για σύνδεση και ενισχυμένη επαλήθευση. Απαιτεί δοκιμασμένη αντιστοίχιση MFA στη διαμόρφωση SSO· χωρίς αυτήν οι δηλώσεις του παρόχου δεν μετρούν ποτέ. Μόνο το διαχειριστικό μέλος. + + + Δέχεται κλειδί πρόσβασης για πρόσβαση σε προστατευμένα δεδομένα, διαχείριση ADP και εγκρίσεις προστατευμένων ροών εργασίας. Οι κωδικοί εφαρμογής ελέγχου ταυτότητας γίνονται πάντα δεκτοί. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα, οπότε τα μέλη επαληθεύουν ξανά. Μόνο το διαχειριστικό μέλος. + + + Δέχεται κλειδί πρόσβασης καταχωρισμένο στην εφαρμογή που υποβάλλει το αίτημα ως MFA για σύνδεση, αλλαγή τμήματος και ενισχυμένη επαλήθευση. Οι κωδικοί εφαρμογής ελέγχου ταυτότητας γίνονται πάντα δεκτοί. Η αλλαγή αυξάνει την έκδοση πολιτικής MFA· οι συνεδρίες των οποίων ο μόνος δεύτερος παράγοντας δεν επιτρέπεται πλέον επαληθεύουν ξανά. Μόνο το διαχειριστικό μέλος. + + + Δέχεται έγκριση με το κλειδί πρόσβασης της εφαρμογής Responder του μέλους όπου η αντίστοιχη ρύθμιση κλειδιού πρόσβασης είναι ενεργή· ποτέ για αλλαγές ασφαλείας ή μεθόδους σύνδεσης του λογαριασμού. Η αλλαγή τερματίζει επίσης την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. Μόνο το διαχειριστικό μέλος. + Επιτρεπόμενα εύρη δικτύου σύνδεσης. Το κενό δεν προσθέτει περιορισμό εύρους. Ελέγξτε τα νόμιμα δίκτυα ανταποκριτών και την πρόσβαση ανάκτησης πριν το περιορίσετε. @@ -5454,6 +5511,15 @@ Χρόνος λήξης αδράνειας σε λεπτά· το μηδέν παραπέμπει στη συμπεριφορά του διακομιστή. Επαληθεύστε τις συνεδρίες που διαχειρίζεται η πολιτική και κάθε υποστηριζόμενη εφαρμογή-πελάτη αντί να υποθέτετε άμεση καθολική αποσύνδεση. + + Λεπτά χωρίς δραστηριότητα του χειριστή πριν κλειδωθεί η συνεδρία κοινόχρηστου tablet οχήματος ή σταθμού εργασίας, από 1 έως 15. Οι ενημερώσεις στο παρασκήνιο και οι εισερχόμενες ειδοποιήσεις δεν θεωρούνται δραστηριότητα. Μια αυστηρότερη τιμή ισχύει για τις τρέχουσες συνεδρίες στο επόμενο αίτημά τους. Μόνο το διαχειριστικό μέλος. + + + Οι συνεδρίες Unit, IC και Dispatch που αυτό το τμήμα εκτελεί πάντα ως κοινόχρηστες, ανεξάρτητα από τη ρύθμιση της εγκατάστασης· μετρούν επίσης οι συνδέσεις που δεν δηλώνουν την εφαρμογή τους. Απαιτεί εκδόσεις εφαρμογών που υποστηρίζουν την κοινόχρηστη λειτουργία. Μόνο το διαχειριστικό μέλος. + + + Ώρες μετά τη σύνδεση κατά τις οποίες τερματίζεται μια κοινόχρηστη συνεδρία ανεξάρτητα από τη δραστηριότητα, από 1 έως 24. Μια μικρότερη τιμή τερματίζει νωρίτερα τις τρέχουσες συνεδρίες· μια μεγαλύτερη δεν τις παρατείνει ποτέ. Μόνο το διαχειριστικό μέλος. + Επιτρέπει μια διαδρομή τοπικού κωδικού πρόσβασης παράλληλα με αυτόν τον πάροχο όπου το επιτρέπει η πολιτική. Ελέγξτε μαζί την απαίτηση SSO σε επίπεδο τμήματος και το σχέδιο ανάκτησης. @@ -5490,6 +5556,12 @@ Αναγνωριστικό παρόχου υπηρεσίας SAML καταχωρισμένο στον πάροχο ταυτότητας. Πρέπει να συμφωνεί με τη ρυθμισμένη ενσωμάτωση. + + Ποια αποτελέσματα σύνδεσης στον πάροχο μετρούν ως MFA για την πρόσθετη επαλήθευση μέσω παρόχου. Μια αλλαγή στην αντιστοίχιση, τον εκδότη ή τον πελάτη απαιτεί νέα επιτυχή δοκιμή πριν γίνει ξανά αποδεκτό το MFA του παρόχου. + + + Διεύθυνση σύνδεσης του παρόχου SAML στην οποία η σύνδεση που ξεκινά από το Resgrid στέλνει το αίτημά της. Επαληθεύστε την με τα μεταδεδομένα του παρόχου πριν βασιστείτε σε αυτήν. + Καθιστά αυτή τη ρύθμιση παρόχου διαθέσιμη στην υποστηριζόμενη ροή SSO. Μια αποθηκευμένη ενεργοποιημένη σημαία δεν αποτελεί επιτυχή δοκιμή σύνδεσης. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx index 61bbd0efe..343c3ad4f 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx @@ -5190,6 +5190,27 @@ Department Notification / Users To Notify + + Department Security Policy / Accept Recent Login MFA For ADP + + + Department Security Policy / Accept Recent Unlock MFA For ADP + + + Department Security Policy / Allow Federated MFA For ADP + + + Department Security Policy / Allow Federated MFA For Login MFA + + + Department Security Policy / Allow Passkeys For ADP + + + Department Security Policy / Allow Passkeys For Login MFA + + + Department Security Policy / Allow Responder Approval + Department Security Policy / Allowed IP Ranges @@ -5217,6 +5238,15 @@ Department Security Policy / Session Timeout Minutes + + Department Security Policy / Shared Idle Lock Minutes + + + Department Security Policy / Shared Mode Required Apps + + + Department Security Policy / Shared Shift Hours + Department SSO Config / Allow Local Login @@ -5253,6 +5283,12 @@ Department SSO Config / Entity ID + + Department SSO Config / Federated MFA Mapping JSON + + + Department SSO Config / IdP SSO URL + Department SSO Config / Is Enabled @@ -5427,6 +5463,27 @@ Explicit member recipients. Membership and channel eligibility are evaluated separately at send time. + + Lets MFA completed at sign-in in the same session open protected data within the step-up window, for methods the protected-data settings allow. Changing it ends current protected-data access. Managing member only. + + + Lets the operator's fresh unlock verification on a shared device open protected data. Changing it ends current protected-data access. Managing member only. + + + Accepts the identity provider's MFA for protected data. Requires a tested MFA mapping; changing it ends current protected-data access. Managing member only. + + + Accepts the identity provider's MFA for sign-in and step-up. Requires a tested MFA mapping on the SSO configuration; without one the provider's claims never count. Managing member only. + + + Accepts a passkey for protected-data access, ADP management and protected-workflow approvals. Authenticator codes are always accepted. Changing it ends current protected-data access, so members verify again. Managing member only. + + + Accepts a passkey registered in the requesting app as MFA for sign-in, department entry and step-up. Authenticator codes are always accepted. Changing it advances the MFA policy version; sessions whose only second factor is now disallowed verify again. Managing member only. + + + Accepts approval with the member's Responder app passkey wherever the matching passkey setting is on; never for security changes or account sign-in methods. Changing it also ends current protected-data access. Managing member only. + Allowed login network ranges. Empty adds no range restriction. Review legitimate responder networks and recovery access before narrowing it. @@ -5454,6 +5511,15 @@ Idle timeout in minutes; zero defers to host behavior. Verify policy-managed sessions and each supported client rather than assuming immediate global logout. + + Minutes without operator activity before a shared vehicle tablet or workstation session locks, from 1 to 15. Background updates and incoming alerts are not activity. A stricter value reaches running sessions at their next request. Managing member only. + + + Unit, IC and Dispatch sessions this department always runs as shared sessions, whatever the installation is set to; sign-ins that do not name their app count too. Needs app versions that support shared mode. Managing member only. + + + Hours after sign-in when a shared session ends whatever the activity, from 1 to 24. A shorter value ends running sessions sooner; a longer one never extends them. Managing member only. + Allows a local-password path alongside this provider where policy permits. Review the department-wide SSO requirement and recovery plan together. @@ -5490,6 +5556,12 @@ SAML service-provider identifier registered at the identity provider. It must agree with the configured integration. + + Which provider sign-in results count as MFA for provider step-up. A changed mapping, issuer or client needs a new successful test before provider MFA is accepted again. + + + SAML provider sign-in address that sign-in started from Resgrid sends its request to. Verify it against the provider metadata before relying on it. + Makes this provider configuration available to the supported SSO flow. A saved enabled flag is not a successful sign-in test. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx index 785e0a6c1..5675e044d 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx @@ -865,7 +865,7 @@ Enrutamiento en vivo - Registro + Bitácoras Políticas de mantenimiento y aprobaciones @@ -919,7 +919,7 @@ Nueva factura - Nuevo registro + Nueva bitácora Nueva nota @@ -2842,10 +2842,10 @@ Nombre del menú de inventario - Disponibilidad de registros + Disponibilidad de bitácoras - Nombre del menú de registros + Nombre del menú de bitácoras Disponibilidad de mantenimiento @@ -2980,10 +2980,10 @@ Nombre de visualización opcional para inventario en los consumidores que admiten la anulación. Cambia la etiqueta, no los permisos ni la funcionalidad. Esta versión no tiene un control de anulación de nombre independiente en Configuración de módulos. - Controla la disponibilidad de los registros heredados. Deshabilitar este módulo puede eliminar la navegación y bloquear las operaciones del módulo; no elimina sus registros. Habilitarlo sigue requiriendo la suscripción, el despliegue y los permisos correspondientes. + Controla la disponibilidad de las bitácoras heredadas. Deshabilitar este módulo puede eliminar la navegación y bloquear las operaciones del módulo; no elimina sus entradas. Habilitarlo sigue requiriendo la suscripción, el despliegue y los permisos correspondientes. - Nombre de visualización opcional para registros heredados en los consumidores que admiten la anulación. Cambia la etiqueta, no los permisos ni la funcionalidad. Esta versión no tiene un control de anulación de nombre independiente en Configuración de módulos. + Nombre de visualización opcional para las bitácoras heredadas en los consumidores que admiten la anulación. Cambia la etiqueta, no los permisos ni la funcionalidad. Esta versión no tiene un control de anulación de nombre independiente en Configuración de módulos. Controla la disponibilidad del mantenimiento y las órdenes de trabajo. Deshabilitar este módulo puede eliminar la navegación y bloquear las operaciones del módulo; no elimina sus registros. Habilitarlo sigue requiriendo la suscripción, el despliegue y los permisos correspondientes. @@ -5190,6 +5190,27 @@ Notificación del departamento / Usuarios a notificar + + Política de seguridad del departamento / Aceptar MFA de inicio de sesión reciente para ADP + + + Política de seguridad del departamento / Aceptar MFA de desbloqueo reciente para ADP + + + Política de seguridad del departamento / Permitir MFA federada para ADP + + + Política de seguridad del departamento / Permitir MFA federada para MFA de inicio de sesión + + + Política de seguridad del departamento / Permitir llaves de acceso para ADP + + + Política de seguridad del departamento / Permitir llaves de acceso para MFA de inicio de sesión + + + Política de seguridad del departamento / Permitir aprobación con Responder + Política de seguridad del departamento / Rangos de IP permitidos @@ -5217,6 +5238,15 @@ Política de seguridad del departamento / Minutos de tiempo de espera de sesión + + Política de seguridad del departamento / Minutos de inactividad antes de bloquear sesiones compartidas + + + Política de seguridad del departamento / Aplicaciones con modo compartido obligatorio + + + Política de seguridad del departamento / Horas de turno de las sesiones compartidas + Configuración de SSO del departamento / Permitir inicio de sesión local @@ -5253,6 +5283,12 @@ Configuración de SSO del departamento / ID de entidad + + Configuración de SSO del departamento / JSON de asignación de MFA federada + + + Configuración de SSO del departamento / URL de SSO del IdP + Configuración de SSO del departamento / Está habilitado @@ -5427,6 +5463,27 @@ Destinatarios explícitos de miembros. La membresía y la elegibilidad de canal se evalúan por separado en el momento del envío. + + Permite que la MFA completada al iniciar sesión en la misma sesión abra datos protegidos dentro de la ventana de verificación reforzada, para los métodos que permiten los ajustes de datos protegidos. Cambiarlo finaliza el acceso actual a los datos protegidos. Solo el miembro administrador. + + + Permite que la verificación de desbloqueo reciente del operador en un dispositivo compartido abra datos protegidos. Cambiarlo finaliza el acceso actual a los datos protegidos. Solo el miembro administrador. + + + Acepta la MFA del proveedor de identidad para datos protegidos. Requiere una asignación de MFA probada; cambiarlo finaliza el acceso actual a los datos protegidos. Solo el miembro administrador. + + + Acepta la MFA del proveedor de identidad para el inicio de sesión y la verificación reforzada. Requiere una asignación de MFA probada en la configuración de SSO; sin ella, las afirmaciones del proveedor nunca cuentan. Solo el miembro administrador. + + + Acepta una llave de acceso para el acceso a datos protegidos, la administración de ADP y las aprobaciones de flujos de trabajo protegidos. Los códigos del autenticador siempre se aceptan. Cambiarlo finaliza el acceso actual a los datos protegidos, por lo que los miembros vuelven a verificar. Solo el miembro administrador. + + + Acepta una llave de acceso registrada en la aplicación solicitante como MFA para el inicio de sesión, el cambio de departamento y la verificación reforzada. Los códigos del autenticador siempre se aceptan. Cambiarlo incrementa la versión de la política de MFA; las sesiones cuyo único segundo factor ya no está permitido vuelven a verificar. Solo el miembro administrador. + + + Acepta la aprobación con la llave de acceso de la aplicación Responder del miembro donde el ajuste de llave de acceso correspondiente está activado; nunca para cambios de seguridad ni métodos de inicio de sesión de la cuenta. Cambiarlo también finaliza el acceso actual a los datos protegidos. Solo el miembro administrador. + Rangos de red permitidos para el inicio de sesión. Si está vacío, no se agrega ninguna restricción de rango. Revise las redes legítimas de los respondedores y el acceso de recuperación antes de restringirlo. @@ -5454,6 +5511,15 @@ Tiempo de espera por inactividad en minutos; cero delega el comportamiento al proveedor. Verifique las sesiones administradas por la política y cada cliente admitido en lugar de asumir un cierre de sesión global inmediato. + + Minutos sin actividad del operador antes de que se bloquee la sesión de una tableta de vehículo o un puesto de trabajo compartido, de 1 a 15. Las actualizaciones en segundo plano y las alertas entrantes no cuentan como actividad. Un valor más estricto se aplica a las sesiones en curso en su siguiente solicitud. Solo el miembro administrador. + + + Sesiones de Unit, IC y Dispatch que este departamento ejecuta siempre como sesiones compartidas, sin importar la configuración de la instalación; también cuentan los inicios de sesión que no indican su aplicación. Requiere versiones de las aplicaciones compatibles con el modo compartido. Solo el miembro administrador. + + + Horas después del inicio de sesión en que termina una sesión compartida, sin importar la actividad, de 1 a 24. Un valor más corto termina antes las sesiones en curso; uno más largo nunca las extiende. Solo el miembro administrador. + Permite una ruta de contraseña local junto con este proveedor cuando la política lo permite. Revise en conjunto el requisito de SSO para todo el departamento y el plan de recuperación. @@ -5490,6 +5556,12 @@ Identificador del proveedor de servicio SAML registrado en el proveedor de identidad. Debe coincidir con la integración configurada. + + Qué resultados de inicio de sesión del proveedor cuentan como MFA para la verificación adicional del proveedor. Un cambio en la asignación, el emisor o el cliente requiere una nueva prueba satisfactoria antes de volver a aceptar la MFA del proveedor. + + + Dirección de inicio de sesión del proveedor SAML a la que el inicio de sesión iniciado desde Resgrid envía su solicitud. Verifíquela con los metadatos del proveedor antes de confiar en ella. + Hace que esta configuración de proveedor esté disponible para el flujo de SSO admitido. Un indicador de habilitado guardado no equivale a una prueba de inicio de sesión exitosa. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx index c6ee697cf..cadf16cd9 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx @@ -5190,6 +5190,27 @@ Notification du département / Utilisateurs à notifier + + Politique de sécurité du département / Accepter la MFA de connexion récente pour ADP + + + Politique de sécurité du département / Accepter la MFA de déverrouillage récente pour ADP + + + Politique de sécurité du département / Autoriser la MFA fédérée pour ADP + + + Politique de sécurité du département / Autoriser la MFA fédérée pour la MFA de connexion + + + Politique de sécurité du département / Autoriser les clés d'accès pour ADP + + + Politique de sécurité du département / Autoriser les clés d'accès pour la MFA de connexion + + + Politique de sécurité du département / Autoriser l'approbation Responder + Politique de sécurité du département / Plages d’adresses IP autorisées @@ -5217,6 +5238,15 @@ Politique de sécurité du département / Délai d’expiration de session (minutes) + + Politique de sécurité du département / Minutes d'inactivité avant le verrouillage des sessions partagées + + + Politique de sécurité du département / Applications en mode partagé obligatoire + + + Politique de sécurité du département / Durée de service des sessions partagées (heures) + Configuration SSO du département / Autoriser la connexion locale @@ -5253,6 +5283,12 @@ Configuration SSO du département / ID d’entité + + Configuration SSO du département / Mappage MFA fédérée (JSON) + + + Configuration SSO du département / URL SSO de l’IdP + Configuration SSO du département / Activé @@ -5427,6 +5463,27 @@ Destinataires membres explicites. L’appartenance et l’éligibilité des canaux sont évaluées séparément au moment de l’envoi. + + Permet à la MFA effectuée à la connexion, dans la même session, d'ouvrir les données protégées pendant la fenêtre de vérification renforcée, pour les méthodes autorisées par les paramètres des données protégées. Une modification met fin à l'accès actuel aux données protégées. Membre gestionnaire uniquement. + + + Permet à la vérification de déverrouillage récente de l'opérateur sur un appareil partagé d'ouvrir les données protégées. Une modification met fin à l'accès actuel aux données protégées. Membre gestionnaire uniquement. + + + Accepte la MFA du fournisseur d'identité pour les données protégées. Nécessite un mappage MFA testé ; une modification met fin à l'accès actuel aux données protégées. Membre gestionnaire uniquement. + + + Accepte la MFA du fournisseur d'identité pour la connexion et la vérification renforcée. Nécessite un mappage MFA testé dans la configuration SSO ; sans lui, les assertions du fournisseur ne comptent jamais. Membre gestionnaire uniquement. + + + Accepte une clé d'accès pour l'accès aux données protégées, la gestion ADP et les approbations de workflows protégés. Les codes d'authentificateur sont toujours acceptés. Une modification met fin à l'accès actuel aux données protégées ; les membres se vérifient à nouveau. Membre gestionnaire uniquement. + + + Accepte une clé d'accès enregistrée dans l'application demandeuse comme MFA pour la connexion, le changement de département et la vérification renforcée. Les codes d'authentificateur sont toujours acceptés. Une modification incrémente la version de la politique MFA ; les sessions dont le seul second facteur n'est plus autorisé se vérifient à nouveau. Membre gestionnaire uniquement. + + + Accepte l'approbation avec la clé d'accès de l'application Responder du membre lorsque le paramètre de clé d'accès correspondant est activé ; jamais pour les modifications de sécurité ni les méthodes de connexion du compte. Une modification met aussi fin à l'accès actuel aux données protégées. Membre gestionnaire uniquement. + Plages réseau autorisées pour la connexion. Une valeur vide n’ajoute aucune restriction de plage. Examinez les réseaux légitimes des intervenants et l’accès de récupération avant de la restreindre. @@ -5454,6 +5511,15 @@ Délai d’inactivité en minutes ; zéro s’en remet au comportement de l’hébergeur. Vérifiez les sessions gérées par la politique et chaque client pris en charge plutôt que de supposer une déconnexion globale immédiate. + + Minutes sans activité de l'opérateur avant le verrouillage de la session d'une tablette de véhicule ou d'un poste de travail partagé, de 1 à 15. Les mises à jour en arrière-plan et les alertes entrantes ne comptent pas comme activité. Une valeur plus stricte s'applique aux sessions en cours à leur prochaine requête. Membre gestionnaire uniquement. + + + Sessions Unit, IC et Dispatch que ce département exécute toujours comme sessions partagées, quel que soit le réglage de l'installation ; les connexions qui n'indiquent pas leur application comptent aussi. Nécessite des versions d'application compatibles avec le mode partagé. Membre gestionnaire uniquement. + + + Nombre d'heures après la connexion au bout desquelles une session partagée se termine, quelle que soit l'activité, de 1 à 24. Une valeur plus courte termine plus tôt les sessions en cours ; une valeur plus longue ne les prolonge jamais. Membre gestionnaire uniquement. + Autorise un mode de connexion par mot de passe local en parallèle de ce fournisseur, lorsque la politique le permet. Examinez ensemble l’exigence de SSO à l’échelle du département et le plan de récupération. @@ -5490,6 +5556,12 @@ Identifiant du fournisseur de service SAML enregistré auprès du fournisseur d’identité. Il doit correspondre à l’intégration configurée. + + Les résultats de connexion du fournisseur qui comptent comme MFA pour la vérification renforcée par le fournisseur. Une modification du mappage, de l’émetteur ou du client exige un nouveau test réussi avant que la MFA du fournisseur soit de nouveau acceptée. + + + Adresse de connexion du fournisseur SAML à laquelle une connexion lancée depuis Resgrid envoie sa demande. Vérifiez-la par rapport aux métadonnées du fournisseur avant de vous y fier. + Rend cette configuration de fournisseur disponible pour le flux SSO pris en charge. Un indicateur d’activation enregistré ne constitue pas un test de connexion réussi. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx index 82ec8859b..deea44d7f 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx @@ -187,7 +187,7 @@ Canali vocali all'interno delle app Resgrid per il coordinamento del team. - Rapporti e record di incidente, moduli record, registri delle attività e rapporti del dipartimento. + Rapporti e record di incidente, moduli record, log delle attività e rapporti del dipartimento. Profilo e impostazioni del dipartimento, quali aree sono attivate, permessi, accesso a due fattori e regole di sessione. @@ -865,7 +865,7 @@ Instradamento in Tempo Reale - Registri + Log Politiche di manutenzione e approvazioni @@ -919,7 +919,7 @@ Nuova Fattura - Nuovo Registro + Nuovo log Nuova Nota @@ -1357,7 +1357,7 @@ Indicazioni e percorsi per le risorse in risposta. - Registri delle attività per chiamate, formazione e lavoro. + Log delle attività per chiamate, formazione e lavoro. Approvazioni di riparazione e fermi di sicurezza. @@ -1411,7 +1411,7 @@ Crea una fattura per un cliente. - Registra un rapporto di intervento, un registro di formazione o un registro di lavoro. + Registra un rapporto di intervento, un log di formazione o un log di lavoro. Pubblica una nota del dipartimento. @@ -1705,7 +1705,7 @@ Configura le impostazioni di fatturazione e le tariffe, quindi crea le fatture. - Scegli i tipi di registro che i membri completano. + Scegli i tipi di log che i membri completano. Decidi chi approva le riparazioni e rimette in servizio l'attrezzatura. @@ -2245,7 +2245,7 @@ Istantanea storica della politica utilizzata per risolvere la conservazione per le revisioni precedenti. Non modificarla mai come una normale preferenza. - Include i registri legacy supportati di personale e unità nell'ambito di ricerca dei Record. Si applicano comunque i permessi di origine. + Include i log legacy supportati di personale e unità nell'ambito di ricerca dei Registri. Si applicano comunque i permessi di origine. Include la narrativa non protetta nella ricerca. La registrazione ad Advanced Data Protection ritira l'indicizzazione della narrativa; questa preferenza non può sovrascrivere la protezione. @@ -2842,10 +2842,10 @@ Nome menu Inventario - Disponibilità Registri + Disponibilità Log - Nome menu Registri + Nome menu Log Disponibilità Manutenzione @@ -2980,10 +2980,10 @@ Nome visualizzato facoltativo per l'inventario nei consumatori che supportano la sovrascrittura. Modifica l'etichetta, non i permessi o la funzionalità. Questa versione non dispone di un controllo separato di sovrascrittura del nome in Impostazioni area. - Controlla la disponibilità dei registri legacy. Disabilitare questa area può rimuovere la navigazione e bloccare le operazioni dell'area; non elimina i relativi record. Attivarla richiede comunque l'abbonamento, il rilascio e i permessi applicabili. + Controlla la disponibilità dei log legacy. Disabilitare questa area può rimuovere la navigazione e bloccare le operazioni dell'area; non elimina i relativi record. Attivarla richiede comunque l'abbonamento, il rilascio e i permessi applicabili. - Nome visualizzato facoltativo per i registri legacy nei consumatori che supportano la sovrascrittura. Modifica l'etichetta, non i permessi o la funzionalità. Questa versione non dispone di un controllo separato di sovrascrittura del nome in Impostazioni area. + Nome visualizzato facoltativo per i log legacy nei consumatori che supportano la sovrascrittura. Modifica l'etichetta, non i permessi o la funzionalità. Questa versione non dispone di un controllo separato di sovrascrittura del nome in Impostazioni area. Controlla la disponibilità di manutenzione e ordini di lavoro. Disabilitare questa area può rimuovere la navigazione e bloccare le operazioni dell'area; non elimina i relativi record. Attivarla richiede comunque l'abbonamento, il rilascio e i permessi applicabili. @@ -3127,7 +3127,7 @@ Modifica Record - Approvare rapporti + Approvare registri Approva Rapporti Orari @@ -3169,7 +3169,7 @@ Crea Documento - Crea Registro + Crea log Crea Messaggio @@ -3193,7 +3193,7 @@ Elimina Chiamata - Elimina Registro + Elimina log Elimina Record @@ -3214,7 +3214,7 @@ Esporta Record - Finalizzare rapporti + Finalizzare registri Assegna Inventario @@ -3253,10 +3253,10 @@ Gestisci Rendicontazione Dati Retributivi - Gestire definizioni di rapporto + Gestire definizioni di registro - Gestire divulgazioni di rapporti + Gestire divulgazioni di registri Gestisci Fermo Legale dei Record @@ -3277,7 +3277,7 @@ Gestisci retribuzione del personale - Pubblicare definizioni di rapporto + Pubblicare definizioni di registro Riassegna bozze di record @@ -3289,7 +3289,7 @@ Rimuovi personale - Revisionare rapporti + Revisionare registri Condividi record esternamente @@ -5190,6 +5190,27 @@ Notifica del dipartimento / Utenti da notificare + + Policy di sicurezza del dipartimento / Accetta MFA di accesso recente per ADP + + + Policy di sicurezza del dipartimento / Accetta MFA di sblocco recente per ADP + + + Policy di sicurezza del dipartimento / Consenti MFA federata per ADP + + + Policy di sicurezza del dipartimento / Consenti MFA federata per la MFA di accesso + + + Policy di sicurezza del dipartimento / Consenti passkey per ADP + + + Policy di sicurezza del dipartimento / Consenti passkey per la MFA di accesso + + + Policy di sicurezza del dipartimento / Consenti approvazione con Responder + Policy di sicurezza del dipartimento / Intervalli IP consentiti @@ -5217,6 +5238,15 @@ Policy di sicurezza del dipartimento / Timeout sessione in minuti + + Policy di sicurezza del dipartimento / Minuti di inattività prima del blocco delle sessioni condivise + + + Policy di sicurezza del dipartimento / App con modalità condivisa obbligatoria + + + Policy di sicurezza del dipartimento / Ore di turno delle sessioni condivise + Configurazione SSO del dipartimento / Consenti accesso locale @@ -5253,6 +5283,12 @@ Configurazione SSO del dipartimento / ID entità + + Configurazione SSO del dipartimento / Mappatura MFA federata JSON + + + Configurazione SSO del dipartimento / URL SSO dell'IdP + Configurazione SSO del dipartimento / Abilitata @@ -5427,6 +5463,27 @@ Destinatari espliciti tra i membri. L'appartenenza e l'idoneità del canale vengono valutate separatamente al momento dell'invio. + + Consente alla MFA completata all'accesso nella stessa sessione di aprire i dati protetti entro la finestra di verifica rafforzata, per i metodi consentiti dalle impostazioni dei dati protetti. Modificarla termina l'accesso attuale ai dati protetti. Solo il membro responsabile. + + + Consente alla verifica di sblocco recente dell'operatore su un dispositivo condiviso di aprire i dati protetti. Modificarla termina l'accesso attuale ai dati protetti. Solo il membro responsabile. + + + Accetta la MFA del provider di identità per i dati protetti. Richiede una mappatura MFA testata; modificarla termina l'accesso attuale ai dati protetti. Solo il membro responsabile. + + + Accetta la MFA del provider di identità per l'accesso e la verifica rafforzata. Richiede una mappatura MFA testata nella configurazione SSO; senza di essa le attestazioni del provider non contano mai. Solo il membro responsabile. + + + Accetta una passkey per l'accesso ai dati protetti, la gestione ADP e le approvazioni dei workflow protetti. I codici dell'autenticatore sono sempre accettati. Modificarla termina l'accesso attuale ai dati protetti, quindi i membri si verificano di nuovo. Solo il membro responsabile. + + + Accetta una passkey registrata nell'app richiedente come MFA per l'accesso, il cambio di dipartimento e la verifica rafforzata. I codici dell'autenticatore sono sempre accettati. Modificarla fa avanzare la versione della policy MFA; le sessioni il cui unico secondo fattore non è più consentito si verificano di nuovo. Solo il membro responsabile. + + + Accetta l'approvazione con la passkey dell'app Responder del membro dove l'impostazione passkey corrispondente è attiva; mai per modifiche di sicurezza o metodi di accesso dell'account. Modificarla termina anche l'accesso attuale ai dati protetti. Solo il membro responsabile. + Intervalli di rete consentiti per l'accesso. Vuoto non aggiunge alcuna restrizione di intervallo. Rivedi le reti legittime dei soccorritori e l'accesso di ripristino prima di restringerlo. @@ -5454,6 +5511,15 @@ Timeout di inattività in minuti; zero rimanda al comportamento dell'host. Verifica le sessioni gestite dalla policy e ciascun client supportato anziché presumere una disconnessione globale immediata. + + Minuti senza attività dell'operatore prima che la sessione di un tablet di un veicolo o di una postazione condivisa si blocchi, da 1 a 15. Gli aggiornamenti in background e gli avvisi in arrivo non contano come attività. Un valore più restrittivo si applica alle sessioni in corso alla loro richiesta successiva. Solo il membro responsabile. + + + Sessioni di Unit, IC e Dispatch che questo dipartimento esegue sempre come sessioni condivise, indipendentemente dall'impostazione dell'installazione; contano anche gli accessi che non indicano la propria app. Richiede versioni delle app che supportano la modalità condivisa. Solo il membro responsabile. + + + Ore dopo l'accesso al termine delle quali una sessione condivisa finisce, indipendentemente dall'attività, da 1 a 24. Un valore più breve termina prima le sessioni in corso; uno più lungo non le estende mai. Solo il membro responsabile. + Consente un percorso con password locale insieme a questo provider dove la policy lo permette. Rivedi insieme il requisito SSO a livello di dipartimento e il piano di ripristino. @@ -5490,6 +5556,12 @@ Identificativo del service provider SAML registrato presso il provider di identità. Deve corrispondere all'integrazione configurata. + + Quali risultati di accesso del provider valgono come MFA per la verifica aggiuntiva tramite provider. Una modifica alla mappatura, all’emittente o al client richiede un nuovo test riuscito prima che la MFA del provider venga di nuovo accettata. + + + Indirizzo di accesso del provider SAML a cui l'accesso avviato da Resgrid invia la propria richiesta. Verificalo con i metadati del provider prima di farvi affidamento. + Rende disponibile questa configurazione del provider al flusso SSO supportato. Un flag di abilitazione salvato non è un test di accesso riuscito. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx index 281fa0124..9e34370b9 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx @@ -5190,6 +5190,27 @@ Powiadomienie oddziału / Użytkownicy do powiadomienia + + Zasady zabezpieczeń oddziału / Akceptuj niedawne MFA logowania dla ADP + + + Zasady zabezpieczeń oddziału / Akceptuj niedawne MFA odblokowania dla ADP + + + Zasady zabezpieczeń oddziału / Zezwalaj na federacyjne MFA dla ADP + + + Zasady zabezpieczeń oddziału / Zezwalaj na federacyjne MFA dla MFA logowania + + + Zasady zabezpieczeń oddziału / Zezwalaj na klucze dostępu dla ADP + + + Zasady zabezpieczeń oddziału / Zezwalaj na klucze dostępu dla MFA logowania + + + Zasady zabezpieczeń oddziału / Zezwalaj na zatwierdzanie w aplikacji Responder + Zasady zabezpieczeń oddziału / Dozwolone zakresy adresów IP @@ -5217,6 +5238,15 @@ Zasady zabezpieczeń oddziału / Limit czasu sesji (w minutach) + + Zasady zabezpieczeń oddziału / Minuty bezczynności przed zablokowaniem sesji współdzielonej + + + Zasady zabezpieczeń oddziału / Aplikacje z wymuszonym trybem współdzielonym + + + Zasady zabezpieczeń oddziału / Długość zmiany sesji współdzielonej (w godzinach) + Konfiguracja SSO oddziału / Zezwalaj na logowanie lokalne @@ -5253,6 +5283,12 @@ Konfiguracja SSO oddziału / Identyfikator jednostki (Entity ID) + + Konfiguracja SSO oddziału / Mapowanie federacyjnego MFA (JSON) + + + Konfiguracja SSO oddziału / Adres URL SSO dostawcy tożsamości + Konfiguracja SSO oddziału / Włączone @@ -5427,6 +5463,27 @@ Jawnie wskazani odbiorcy spośród członków. Członkostwo i kwalifikowalność kanału są oceniane osobno w chwili wysyłki. + + Pozwala, aby MFA ukończone przy logowaniu w tej samej sesji otwierało dane chronione w oknie wzmocnionej weryfikacji, dla metod dozwolonych przez ustawienia danych chronionych. Zmiana kończy bieżący dostęp do danych chronionych. Tylko członek zarządzający. + + + Pozwala, aby świeża weryfikacja odblokowania operatora na urządzeniu współdzielonym otwierała dane chronione. Zmiana kończy bieżący dostęp do danych chronionych. Tylko członek zarządzający. + + + Akceptuje MFA dostawcy tożsamości dla danych chronionych. Wymaga przetestowanego mapowania MFA; zmiana kończy bieżący dostęp do danych chronionych. Tylko członek zarządzający. + + + Akceptuje MFA dostawcy tożsamości przy logowaniu i wzmocnionej weryfikacji. Wymaga przetestowanego mapowania MFA w konfiguracji SSO; bez niego oświadczenia dostawcy nigdy się nie liczą. Tylko członek zarządzający. + + + Akceptuje klucz dostępu przy dostępie do danych chronionych, zarządzaniu ADP i zatwierdzaniu chronionych przepływów pracy. Kody z aplikacji uwierzytelniającej są zawsze akceptowane. Zmiana kończy bieżący dostęp do danych chronionych, więc członkowie weryfikują się ponownie. Tylko członek zarządzający. + + + Akceptuje klucz dostępu zarejestrowany w aplikacji zgłaszającej żądanie jako MFA przy logowaniu, zmianie oddziału i wzmocnionej weryfikacji. Kody z aplikacji uwierzytelniającej są zawsze akceptowane. Zmiana podnosi wersję zasad MFA; sesje, których jedyny drugi składnik nie jest już dozwolony, weryfikują się ponownie. Tylko członek zarządzający. + + + Akceptuje zatwierdzenie kluczem dostępu w aplikacji Responder członka tam, gdzie odpowiednie ustawienie klucza dostępu jest włączone; nigdy przy zmianach zabezpieczeń ani metodach logowania konta. Zmiana kończy też bieżący dostęp do danych chronionych. Tylko członek zarządzający. + Dozwolone zakresy sieciowe do logowania. Puste pole oznacza brak ograniczenia zakresu. Przed zawężeniem sprawdź sieci uprawnionych ratowników oraz dostęp do odzyskiwania. @@ -5454,6 +5511,15 @@ Limit czasu bezczynności w minutach — zero oznacza zachowanie zgodne z ustawieniami hosta. Zweryfikuj sesje zarządzane przez zasady oraz każdą obsługiwaną aplikację kliencką, zamiast zakładać natychmiastowe globalne wylogowanie. + + Liczba minut bez aktywności operatora, po których sesja współdzielonego tabletu w pojeździe lub stanowiska pracy zostaje zablokowana, od 1 do 15. Aktualizacje w tle i przychodzące alerty nie są aktywnością. Bardziej restrykcyjna wartość obejmuje trwające sesje przy ich następnym żądaniu. Tylko członek zarządzający. + + + Sesje Unit, IC i Dispatch, które ten oddział zawsze prowadzi jako sesje współdzielone, niezależnie od ustawienia instalacji; liczą się też logowania, które nie podają swojej aplikacji. Wymaga wersji aplikacji obsługujących tryb współdzielony. Tylko członek zarządzający. + + + Liczba godzin od zalogowania, po których sesja współdzielona kończy się niezależnie od aktywności, od 1 do 24. Krótsza wartość wcześniej kończy trwające sesje; dłuższa nigdy ich nie wydłuża. Tylko członek zarządzający. + Zezwala na ścieżkę logowania hasłem lokalnym obok tego dostawcy tam, gdzie pozwalają na to zasady. Sprawdź razem wymóg SSO obowiązujący w całym oddziale oraz plan odzyskiwania dostępu. @@ -5490,6 +5556,12 @@ Identyfikator dostawcy usługi SAML zarejestrowany u dostawcy tożsamości. Musi być zgodny ze skonfigurowaną integracją. + + Które wyniki logowania u dostawcy są uznawane za MFA przy dodatkowej weryfikacji przez dostawcę. Zmiana mapowania, wystawcy lub klienta wymaga nowego udanego testu, zanim MFA dostawcy zostanie ponownie zaakceptowane. + + + Adres logowania dostawcy SAML, do którego logowanie rozpoczęte w Resgrid wysyła żądanie. Sprawdź go z metadanymi dostawcy, zanim zaczniesz na nim polegać. + Udostępnia tę konfigurację dostawcy obsługiwanemu procesowi SSO. Zapisana flaga włączenia nie jest dowodem powodzenia testu logowania. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx index 61bbd0efe..343c3ad4f 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx @@ -5190,6 +5190,27 @@ Department Notification / Users To Notify + + Department Security Policy / Accept Recent Login MFA For ADP + + + Department Security Policy / Accept Recent Unlock MFA For ADP + + + Department Security Policy / Allow Federated MFA For ADP + + + Department Security Policy / Allow Federated MFA For Login MFA + + + Department Security Policy / Allow Passkeys For ADP + + + Department Security Policy / Allow Passkeys For Login MFA + + + Department Security Policy / Allow Responder Approval + Department Security Policy / Allowed IP Ranges @@ -5217,6 +5238,15 @@ Department Security Policy / Session Timeout Minutes + + Department Security Policy / Shared Idle Lock Minutes + + + Department Security Policy / Shared Mode Required Apps + + + Department Security Policy / Shared Shift Hours + Department SSO Config / Allow Local Login @@ -5253,6 +5283,12 @@ Department SSO Config / Entity ID + + Department SSO Config / Federated MFA Mapping JSON + + + Department SSO Config / IdP SSO URL + Department SSO Config / Is Enabled @@ -5427,6 +5463,27 @@ Explicit member recipients. Membership and channel eligibility are evaluated separately at send time. + + Lets MFA completed at sign-in in the same session open protected data within the step-up window, for methods the protected-data settings allow. Changing it ends current protected-data access. Managing member only. + + + Lets the operator's fresh unlock verification on a shared device open protected data. Changing it ends current protected-data access. Managing member only. + + + Accepts the identity provider's MFA for protected data. Requires a tested MFA mapping; changing it ends current protected-data access. Managing member only. + + + Accepts the identity provider's MFA for sign-in and step-up. Requires a tested MFA mapping on the SSO configuration; without one the provider's claims never count. Managing member only. + + + Accepts a passkey for protected-data access, ADP management and protected-workflow approvals. Authenticator codes are always accepted. Changing it ends current protected-data access, so members verify again. Managing member only. + + + Accepts a passkey registered in the requesting app as MFA for sign-in, department entry and step-up. Authenticator codes are always accepted. Changing it advances the MFA policy version; sessions whose only second factor is now disallowed verify again. Managing member only. + + + Accepts approval with the member's Responder app passkey wherever the matching passkey setting is on; never for security changes or account sign-in methods. Changing it also ends current protected-data access. Managing member only. + Allowed login network ranges. Empty adds no range restriction. Review legitimate responder networks and recovery access before narrowing it. @@ -5454,6 +5511,15 @@ Idle timeout in minutes; zero defers to host behavior. Verify policy-managed sessions and each supported client rather than assuming immediate global logout. + + Minutes without operator activity before a shared vehicle tablet or workstation session locks, from 1 to 15. Background updates and incoming alerts are not activity. A stricter value reaches running sessions at their next request. Managing member only. + + + Unit, IC and Dispatch sessions this department always runs as shared sessions, whatever the installation is set to; sign-ins that do not name their app count too. Needs app versions that support shared mode. Managing member only. + + + Hours after sign-in when a shared session ends whatever the activity, from 1 to 24. A shorter value ends running sessions sooner; a longer one never extends them. Managing member only. + Allows a local-password path alongside this provider where policy permits. Review the department-wide SSO requirement and recovery plan together. @@ -5490,6 +5556,12 @@ SAML service-provider identifier registered at the identity provider. It must agree with the configured integration. + + Which provider sign-in results count as MFA for provider step-up. A changed mapping, issuer or client needs a new successful test before provider MFA is accepted again. + + + SAML provider sign-in address that sign-in started from Resgrid sends its request to. Verify it against the provider metadata before relying on it. + Makes this provider configuration available to the supported SSO flow. A saved enabled flag is not a successful sign-in test. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx index bcfb1e4f9..59bbc39b6 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx @@ -5190,6 +5190,27 @@ Avdelningsavisering / Användare att avisera + + Säkerhetspolicy för avdelning / Godkänn nylig inloggnings-MFA för ADP + + + Säkerhetspolicy för avdelning / Godkänn nylig upplåsnings-MFA för ADP + + + Säkerhetspolicy för avdelning / Tillåt federerad MFA för ADP + + + Säkerhetspolicy för avdelning / Tillåt federerad MFA för inloggnings-MFA + + + Säkerhetspolicy för avdelning / Tillåt lösennycklar för ADP + + + Säkerhetspolicy för avdelning / Tillåt lösennycklar för inloggnings-MFA + + + Säkerhetspolicy för avdelning / Tillåt godkännande i Responder + Säkerhetspolicy för avdelning / Tillåtna IP-intervall @@ -5217,6 +5238,15 @@ Säkerhetspolicy för avdelning / Sessionens tidsgräns i minuter + + Säkerhetspolicy för avdelning / Minuter av inaktivitet innan delade sessioner låses + + + Säkerhetspolicy för avdelning / Appar med tvingat delat läge + + + Säkerhetspolicy för avdelning / Skiftlängd för delade sessioner (timmar) + SSO-konfiguration för avdelning / Tillåt lokal inloggning @@ -5253,6 +5283,12 @@ SSO-konfiguration för avdelning / Entitets-id + + SSO-konfiguration för avdelning / Mappning för federerad MFA (JSON) + + + SSO-konfiguration för avdelning / IdP:ns SSO-URL + SSO-konfiguration för avdelning / Aktiverad @@ -5427,6 +5463,27 @@ Explicita medlemsmottagare. Medlemskap och kanalbehörighet utvärderas separat vid sändningstillfället. + + Låter MFA som slutförts vid inloggningen i samma session öppna skyddade data inom fönstret för förstärkt verifiering, för metoder som inställningarna för skyddade data tillåter. En ändring avslutar nuvarande åtkomst till skyddade data. Endast förvaltande medlem. + + + Låter operatörens färska upplåsningsverifiering på en delad enhet öppna skyddade data. En ändring avslutar nuvarande åtkomst till skyddade data. Endast förvaltande medlem. + + + Godkänner identitetsleverantörens MFA för skyddade data. Kräver en testad MFA-mappning; en ändring avslutar nuvarande åtkomst till skyddade data. Endast förvaltande medlem. + + + Godkänner identitetsleverantörens MFA för inloggning och förstärkt verifiering. Kräver en testad MFA-mappning i SSO-konfigurationen; utan den räknas leverantörens påståenden aldrig. Endast förvaltande medlem. + + + Godkänner en lösennyckel för åtkomst till skyddade data, ADP-hantering och godkännanden av skyddade arbetsflöden. Autentiseringskoder godkänns alltid. En ändring avslutar nuvarande åtkomst till skyddade data, så medlemmar verifierar igen. Endast förvaltande medlem. + + + Godkänner en lösennyckel som registrerats i den begärande appen som MFA för inloggning, byte av avdelning och förstärkt verifiering. Autentiseringskoder godkänns alltid. En ändring höjer versionen av MFA-policyn; sessioner vars enda andra faktor inte längre är tillåten verifierar igen. Endast förvaltande medlem. + + + Godkänner godkännande med lösennyckeln i medlemmens Responder-app där motsvarande lösennyckelinställning är på; aldrig för säkerhetsändringar eller kontots inloggningsmetoder. En ändring avslutar också nuvarande åtkomst till skyddade data. Endast förvaltande medlem. + Tillåtna nätverksintervall för inloggning. Tomt lägger inte till någon intervallbegränsning. Granska legitima insatsnätverk och återställningsåtkomst innan du begränsar det. @@ -5454,6 +5511,15 @@ Tidsgräns för inaktivitet i minuter; noll överlåter till värdens beteende. Verifiera policyhanterade sessioner och varje klient som stöds i stället för att anta omedelbar global utloggning. + + Minuter utan aktivitet från operatören innan sessionen på en delad fordonsplatta eller arbetsstation låses, från 1 till 15. Bakgrundsuppdateringar och inkommande larm räknas inte som aktivitet. Ett striktare värde gäller pågående sessioner vid deras nästa begäran. Endast förvaltande medlem. + + + Unit-, IC- och Dispatch-sessioner som avdelningen alltid kör som delade sessioner, oavsett hur installationen är inställd; inloggningar som inte anger sin app räknas också. Kräver appversioner som stöder delat läge. Endast förvaltande medlem. + + + Timmar efter inloggning då en delad session avslutas oavsett aktivitet, från 1 till 24. Ett kortare värde avslutar pågående sessioner tidigare; ett längre förlänger dem aldrig. Endast förvaltande medlem. + Tillåter en lokal lösenordsväg vid sidan av den här leverantören där policyn tillåter det. Granska det avdelningsövergripande SSO-kravet och återställningsplanen tillsammans. @@ -5490,6 +5556,12 @@ SAML-identifierare för tjänsteleverantören registrerad hos identitetsleverantören. Den måste överensstämma med den konfigurerade integrationen. + + Vilka inloggningsresultat hos leverantören som räknas som MFA vid stegvis verifiering via leverantören. En ändrad mappning, utfärdare eller klient kräver ett nytt lyckat test innan leverantörens MFA godtas igen. + + + SAML-leverantörens inloggningsadress som inloggning som startas från Resgrid skickar sin begäran till. Kontrollera den mot leverantörens metadata innan du förlitar dig på den. + Gör den här leverantörskonfigurationen tillgänglig för det SSO-flöde som stöds. En sparad aktiverad-flagga är inget lyckat inloggningstest. diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx index 8e2d0b835..463d30b4d 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx @@ -5190,6 +5190,27 @@ Сповіщення підрозділу / Користувачі для сповіщення + + Політика безпеки підрозділу / Приймати нещодавнє MFA входу для ADP + + + Політика безпеки підрозділу / Приймати нещодавнє MFA розблокування для ADP + + + Політика безпеки підрозділу / Дозволити федеративне MFA для ADP + + + Політика безпеки підрозділу / Дозволити федеративне MFA для MFA входу + + + Політика безпеки підрозділу / Дозволити ключі доступу для ADP + + + Політика безпеки підрозділу / Дозволити ключі доступу для MFA входу + + + Політика безпеки підрозділу / Дозволити підтвердження в Responder + Політика безпеки підрозділу / Дозволені діапазони IP @@ -5217,6 +5238,15 @@ Політика безпеки підрозділу / Тайм-аут сеансу (хвилини) + + Політика безпеки підрозділу / Хвилини бездіяльності до блокування спільного сеансу + + + Політика безпеки підрозділу / Застосунки з обов'язковим спільним режимом + + + Політика безпеки підрозділу / Тривалість зміни спільного сеансу (години) + SSO підрозділу / Дозволити локальний вхід @@ -5253,6 +5283,12 @@ SSO підрозділу / ID об'єкта (Entity ID) + + SSO підрозділу / JSON зіставлення федеративної MFA + + + SSO підрозділу / URL єдиного входу IdP + SSO підрозділу / Увімкнено @@ -5427,6 +5463,27 @@ Явно вказані отримувачі-учасники. Належність і придатність каналу оцінюються окремо на момент надсилання. + + Дозволяє MFA, виконаному під час входу в тому самому сеансі, відкривати захищені дані в межах вікна посиленої перевірки для методів, дозволених налаштуваннями захищених даних. Зміна завершує поточний доступ до захищених даних. Лише керівний учасник. + + + Дозволяє свіжій перевірці розблокування оператора на спільному пристрої відкривати захищені дані. Зміна завершує поточний доступ до захищених даних. Лише керівний учасник. + + + Приймає MFA постачальника ідентичності для захищених даних. Потребує перевіреного зіставлення MFA; зміна завершує поточний доступ до захищених даних. Лише керівний учасник. + + + Приймає MFA постачальника ідентичності для входу та посиленої перевірки. Потребує перевіреного зіставлення MFA в конфігурації SSO; без нього твердження постачальника ніколи не враховуються. Лише керівний учасник. + + + Приймає ключ доступу для доступу до захищених даних, керування ADP і схвалень захищених робочих процесів. Коди автентифікатора приймаються завжди. Зміна завершує поточний доступ до захищених даних, тож учасники проходять перевірку знову. Лише керівний учасник. + + + Приймає ключ доступу, зареєстрований у застосунку, що надсилає запит, як MFA для входу, зміни підрозділу та посиленої перевірки. Коди автентифікатора приймаються завжди. Зміна підвищує версію політики MFA; сеанси, єдиний другий фактор яких більше не дозволено, проходять перевірку знову. Лише керівний учасник. + + + Приймає підтвердження ключем доступу в застосунку Responder учасника там, де ввімкнено відповідне налаштування ключа доступу; ніколи для змін безпеки чи методів входу облікового запису. Зміна також завершує поточний доступ до захищених даних. Лише керівний учасник. + Дозволені мережеві діапазони для входу. Порожнє значення означає відсутність обмеження за діапазоном. Перш ніж звужувати його, перевірте легітимні мережі реагувальників і доступ для відновлення. @@ -5454,6 +5511,15 @@ Тайм-аут бездіяльності у хвилинах; нуль означає використання поведінки хосту. Перевіряйте керовані політикою сеанси та кожен підтримуваний клієнт, а не покладайтеся на негайний глобальний вихід. + + Кількість хвилин без активності оператора, після яких сеанс спільного планшета в транспортному засобі або робочої станції блокується, від 1 до 15. Фонові оновлення та вхідні сповіщення не вважаються активністю. Суворіше значення застосовується до поточних сеансів під час їхнього наступного запиту. Лише керівний учасник. + + + Сеанси Unit, IC і Dispatch, які цей підрозділ завжди запускає як спільні сеанси, незалежно від налаштування встановлення; також враховуються входи, які не вказують свій застосунок. Потрібні версії застосунків, що підтримують спільний режим. Лише керівний учасник. + + + Кількість годин після входу, по завершенні яких спільний сеанс закінчується незалежно від активності, від 1 до 24. Коротше значення завершує поточні сеанси раніше; довше ніколи їх не подовжує. Лише керівний учасник. + Дозволяє шлях локального пароля поряд із цим провайдером, якщо це допускає політика. Перевіряйте вимогу SSO для всього підрозділу та план відновлення доступу разом. @@ -5490,6 +5556,12 @@ Ідентифікатор постачальника послуг SAML, зареєстрований у постачальника ідентифікації. Він має відповідати налаштованій інтеграції. + + Які результати входу в постачальника зараховуються як MFA для додаткової перевірки через постачальника. Зміна зіставлення, видавця або клієнта потребує нового успішного тесту, перш ніж MFA постачальника знову буде прийнято. + + + Адреса входу провайдера SAML, на яку вхід, розпочатий з Resgrid, надсилає свій запит. Перевірте її за метаданими провайдера, перш ніж на неї покладатися. + Робить це налаштування провайдера доступним для підтримуваного процесу SSO. Збережений прапорець увімкнення не є успішним тестом входу. diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx index 842540f48..abb5c746f 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.ar.resx @@ -402,4 +402,13 @@ طباعة ورقة التسجيل + + لم يعد تسجيل الحضور متاحًا لهذه الفعالية. + + + سُجّل الحضور بواسطة {0} + + + سُجّل الانصراف بواسطة {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx index 2e483357a..d7f0ed86c 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Ganzer Tag @@ -353,4 +344,13 @@ Anmeldeliste drucken + + Das Einchecken ist für diese Veranstaltung nicht mehr möglich. + + + Eingecheckt von {0} + + + Ausgecheckt von {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx index 536ad93df..4911f6a33 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.es.resx @@ -399,4 +399,16 @@ Imprimir Hoja de Registro + + Editar entrada principal + + + El registro de entrada ya no está disponible para este evento. + + + Entrada registrada por {0} + + + Salida registrada por {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx index 57ba01fc2..ae303322e 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Toute la journée @@ -353,4 +344,13 @@ Imprimer la feuille d'émargement + + Le pointage n'est plus disponible pour cet événement. + + + Arrivée enregistrée par {0} + + + Départ enregistré par {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx index 556a7b5bc..0f8ce73b8 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Tutto il giorno @@ -353,4 +344,13 @@ Stampa Foglio Presenze + + La registrazione dell'entrata non è più disponibile per questo evento. + + + Entrata registrata da {0} + + + Uscita registrata da {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx index 06914a150..4857b89d5 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Cały dzień @@ -353,4 +344,13 @@ Drukuj listę obecności + + Zameldowanie nie jest już dostępne dla tego wydarzenia. + + + Zameldował(a): {0} + + + Wymeldował(a): {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx index dd55cb74e..ebe23b212 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Hela dagen @@ -353,4 +344,13 @@ Skriv ut närvarolista + + Incheckning är inte längre möjlig för det här evenemanget. + + + Incheckad av {0} + + + Utcheckad av {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx index 326661447..3c6fe7b9d 100644 --- a/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Calendar/Calendar.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Весь день @@ -353,4 +344,13 @@ Друк листа реєстрації + + Реєстрація на цю подію більше недоступна. + + + Вхід зареєстровано: {0} + + + Вихід зареєстровано: {0} + diff --git a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx index 1c8a0623d..b48485087 100644 --- a/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Checklists/Checklists.es.resx @@ -706,7 +706,7 @@ Las comprobaciones excusadas quedan registradas con el motivo que indique aquí. Retirarla impide nuevas ejecuciones y comprobaciones programadas futuras. El historial existente se conserva. Las comprobaciones se seleccionan por su hora de inicio, no por cuándo se completaron. - El aviso para el que se prepara el paquete. + La llamada para la que se prepara el paquete. Nombre la programación Cómo se llama esta programación y sobre qué se ejecuta. Cuándo se ejecuta diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx index 8671b7ca0..aee1c76e0 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.ar.resx @@ -490,4 +490,268 @@ فتح المبنى + + المسارات + + + المسار + + + اسم المحطة + + + عادي + + + عالٍ + + + حرج + + + اختياري + + + خطة ما قبل الحادث + + + الخطة المسبقة + + + ملفات الموقع + + + إدارة الملفات + + + البناء والإشغال + + + نوع البناء + + + نوع السقف + + + نوع الإشغال + + + ساعات الإشغال + + + مثلاً: الاثنين-الجمعة 08:00-17:00، مع وجود طاقم ليلاً + + + الحمل الإشغالي + + + شاغلون يحتاجون إلى مساعدة + + + قد يحتاج الشاغلون في الموقع إلى مساعدة في الإخلاء + + + من يحتاج إلى المساعدة، وأين، وما نوع المساعدة المطلوبة + + + ملاحظات الإشغال + + + قواطع المرافق + + + قاطع الغاز + + + قاطع الكهرباء + + + قاطع الماء + + + ملاحظات المرافق + + + إمداد المياه + + + أقرب صنبور + + + التدفق المطلوب للإطفاء (غالون/دقيقة) + + + ملاحظات إمداد المياه + + + الوصول + + + موقع صندوق المفاتيح + + + رمز البوابة + + + يُخزَّن مشفّرًا. يظهر للأعضاء الذين يمكنهم عرض جهة الاتصال هذه وللمستجيبين في البلاغات المرتبطة. + + + موقع لوحة الإنذار + + + شركة الإنذار + + + الهاتف + + + ملاحظات الوصول + + + جهات الاتصال في الموقع + + + جهة اتصال الطوارئ + + + جهة الاتصال الثانوية + + + المخاطر والتكتيكات + + + مواد خطرة في الموقع + + + تُخزَّن مواد خطرة أو تُستخدم في الموقع + + + ملاحظات عامة عن المخاطر + + + ملخص تكتيكي + + + دورة المراجعة + + + آخر مراجعة + + + لم تُراجَع قط + + + وضع علامة تمت المراجعة على هذه الخطة المسبقة الآن + + + موعد المراجعة التالية + + + حفظ الخطة المسبقة + + + مخاطر المنشأة + + + مخاطر مصنّفة حسب النوع مع درجة خطورة. تُعرض المخاطر المعلَّمة كتنبيهات على المرسلين عند إضافة جهة الاتصال هذه إلى بلاغ. + + + إضافة خطر + + + تعديل الخطر + + + حفظ الخطر + + + هل تريد إزالة هذا الخطر؟ + + + تعذر حفظ الخطر. + + + يجب أن يكون للخطر عنوان. + + + تنبيه المرسلين عند إضافة جهة الاتصال هذه إلى بلاغ + + + الخطورة + + + العنوان + + + الموقع + + + تنبيه + + + لا توجد مخاطر مسجلة للمنشأة. + + + إحداثيات GPS + + + انقضى موعد مراجعة الخطة المسبقة. + + + مراجعة الخطة المسبقة مستحقة + + + إنشاء خطة مسبقة + + + تعديل الخطة المسبقة + + + لم تُسجَّل خطة ما قبل الحادث لجهة الاتصال هذه. + + + لم تُرفق أي ملفات بجهة الاتصال هذه. + + + رفع ملف + + + صور أو ملفات PDF أو مستندات Office أو نصوص أو CSV أو DWG أو DXF بحجم يصل إلى 30MB. + + + نوع الملف + + + العنوان + + + عنوان اختياري (يُستخدم اسم الملف افتراضيًا) + + + اسم الملف + + + رفع + + + هل تريد إزالة هذا الملف؟ + + + تنزيل + + + الحجم + + + أُضيف في + + + لا + + + البلاغات + + + الملاحظات + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx index 007ee0f92..1b4e4e347 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Objekt öffnen + + Routen + + + Route + + + Stoppname + + + Normal + + + Hoch + + + Kritisch + + + Optional + + + Einsatzplan + + + Einsatzplan + + + Objektdateien + + + Dateien verwalten + + + Bauart & Nutzung + + + Bauart + + + Dachart + + + Nutzungsart + + + Nutzungszeiten + + + z. B. Mo-Fr 08:00-17:00, nachts besetzt + + + Personenzahl + + + Hilfsbedürftige Personen + + + Personen vor Ort benötigen möglicherweise Hilfe bei der Evakuierung + + + Wer, wo und welche Hilfe benötigt wird + + + Hinweise zur Nutzung + + + Versorgungsabsperrungen + + + Gasabsperrung + + + Stromabschaltung + + + Wasserabsperrung + + + Versorgungshinweise + + + Löschwasserversorgung + + + Nächster Hydrant + + + Erforderlicher Löschwasserbedarf (GPM) + + + Hinweise zur Löschwasserversorgung + + + Zugang + + + Standort des Schlüsseldepots + + + Torcode + + + Verschlüsselt gespeichert. Wird Mitgliedern angezeigt, die diesen Kontakt sehen dürfen, sowie Einsatzkräften bei verknüpften Einsätzen. + + + Standort der Alarmzentrale + + + Alarmfirma + + + Telefon + + + Zugangshinweise + + + Ansprechpartner vor Ort + + + Notfallkontakt + + + Zweitkontakt + + + Gefahren & Taktik + + + Gefahrstoffe vor Ort + + + Vor Ort werden Gefahrstoffe gelagert oder verwendet + + + Allgemeine Gefahrenhinweise + + + Taktische Zusammenfassung + + + Überprüfungszyklus + + + Zuletzt überprüft + + + Nie überprüft + + + Diesen Einsatzplan jetzt als überprüft markieren + + + Nächste Überprüfung fällig + + + Einsatzplan speichern + + + Objektgefahren + + + Typisierte Gefahren mit Schweregrad. Als Warnung markierte Gefahren werden Disponenten angezeigt, wenn dieser Kontakt einem Einsatz hinzugefügt wird. + + + Gefahr hinzufügen + + + Gefahr bearbeiten + + + Gefahr speichern + + + Diese Gefahr entfernen? + + + Die Gefahr konnte nicht gespeichert werden. + + + Eine Gefahr benötigt einen Titel. + + + Disponenten warnen, wenn dieser Kontakt einem Einsatz hinzugefügt wird + + + Schweregrad + + + Titel + + + Standort + + + Warnung + + + Keine Objektgefahren erfasst. + + + GPS-Koordinaten + + + Das Überprüfungsdatum des Einsatzplans ist überschritten. + + + Überprüfung des Einsatzplans fällig + + + Einsatzplan erstellen + + + Einsatzplan bearbeiten + + + Für diesen Kontakt wurde kein Einsatzplan erfasst. + + + An diesen Kontakt wurden keine Dateien angehängt. + + + Datei hochladen + + + Bilder, PDF, Office-Dokumente, Text, CSV, DWG oder DXF bis 30 MB. + + + Dateityp + + + Titel + + + Optionaler Titel (Standard ist der Dateiname) + + + Dateiname + + + Hochladen + + + Diese Datei entfernen? + + + Herunterladen + + + Größe + + + Hinzugefügt am + + + Nein + + + Einsätze + + + Notizen + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx index 0cd4e5d23..eca17d2b3 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.el.resx @@ -511,4 +511,247 @@ Άνοιγμα κτιρίου + + Προσχέδιο Επέμβασης + + + Προσχέδιο Επέμβασης + + + Αρχεία Χώρου + + + Διαχείριση Αρχείων + + + Κατασκευή και Χρήση + + + Τύπος Κατασκευής + + + Τύπος Στέγης + + + Τύπος Χρήσης + + + Ώρες Χρήσης + + + π.χ. Δευ-Παρ 08:00-17:00, με προσωπικό κατά τη νύχτα + + + Χωρητικότητα + + + Ένοικοι που Χρειάζονται Βοήθεια + + + Ορισμένοι ένοικοι στον χώρο ενδέχεται να χρειαστούν βοήθεια για την εκκένωση + + + Ποιοι, πού και τι βοήθεια χρειάζεται + + + Σημειώσεις Χρήσης + + + Διακοπή Παροχών + + + Διακοπή Αερίου + + + Διακοπή Ρεύματος + + + Διακοπή Νερού + + + Σημειώσεις Παροχών + + + Υδροδότηση + + + Πλησιέστερος Κρουνός + + + Απαιτούμενη Παροχή (GPM) + + + Σημειώσεις Υδροδότησης + + + Πρόσβαση + + + Θέση Κλειδοθήκης + + + Κωδικός Πύλης + + + Αποθηκεύεται κρυπτογραφημένος. Εμφανίζεται στα μέλη που μπορούν να δουν αυτή την επαφή και στο προσωπικό που ανταποκρίνεται σε συνδεδεμένες κλήσεις. + + + Θέση Πίνακα Συναγερμού + + + Εταιρεία Συναγερμού + + + Τηλέφωνο + + + Σημειώσεις Πρόσβασης + + + Επαφές στον Χώρο + + + Επαφή Έκτακτης Ανάγκης + + + Δευτερεύουσα Επαφή + + + Κίνδυνοι και Τακτική + + + Επικίνδυνα Υλικά στον Χώρο + + + Στον χώρο αποθηκεύονται ή χρησιμοποιούνται επικίνδυνα υλικά + + + Γενικές Σημειώσεις Κινδύνων + + + Τακτική Σύνοψη + + + Κύκλος Αναθεώρησης + + + Τελευταία Αναθεώρηση + + + Δεν έχει αναθεωρηθεί ποτέ + + + Σήμανση του προσχεδίου επέμβασης ως αναθεωρημένου τώρα + + + Προθεσμία Επόμενης Αναθεώρησης + + + Αποθήκευση Προσχεδίου Επέμβασης + + + Κίνδυνοι Χώρου + + + Κίνδυνοι ανά τύπο με βαθμό σοβαρότητας. Οι κίνδυνοι που έχουν σημανθεί ως ειδοποιήσεις εμφανίζονται στους διαχειριστές αποστολών όταν αυτή η επαφή προστίθεται σε κλήση. + + + Προσθήκη Κινδύνου + + + Επεξεργασία Κινδύνου + + + Αποθήκευση Κινδύνου + + + Αφαίρεση αυτού του κινδύνου; + + + Δεν ήταν δυνατή η αποθήκευση του κινδύνου. + + + Ο κίνδυνος πρέπει να έχει τίτλο. + + + Ειδοποίηση των διαχειριστών αποστολών όταν αυτή η επαφή προστίθεται σε κλήση + + + Σοβαρότητα + + + Τίτλος + + + Τοποθεσία + + + Ειδοποίηση + + + Δεν έχουν καταχωριστεί κίνδυνοι χώρου. + + + Συντεταγμένες GPS + + + Η ημερομηνία αναθεώρησης του προσχεδίου επέμβασης έχει παρέλθει. + + + Απαιτείται Αναθεώρηση Προσχεδίου + + + Δημιουργία Προσχεδίου Επέμβασης + + + Επεξεργασία Προσχεδίου Επέμβασης + + + Δεν έχει καταχωριστεί προσχέδιο επέμβασης για αυτή την επαφή. + + + Δεν έχουν επισυναφθεί αρχεία σε αυτή την επαφή. + + + Μεταφόρτωση Αρχείου + + + Εικόνες, PDF, έγγραφα Office, κείμενο, CSV, DWG ή DXF έως 30MB. + + + Τύπος Αρχείου + + + Τίτλος + + + Προαιρετικός τίτλος (αν μείνει κενό, χρησιμοποιείται το όνομα του αρχείου) + + + Όνομα Αρχείου + + + Μεταφόρτωση + + + Αφαίρεση αυτού του αρχείου; + + + Λήψη + + + Μέγεθος + + + Προστέθηκε Στις + + + Όχι + + + Κλήσεις + + + Σημειώσεις + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx index 12dc49c2b..263cbd647 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.es.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Agregar categoría @@ -441,4 +432,268 @@ Abrir ocupación + + Rutas + + + Ruta + + + Nombre de la parada + + + Normal + + + Alta + + + Crítica + + + Opcional + + + Plan previo al incidente + + + Plan previo + + + Archivos del sitio + + + Administrar archivos + + + Construcción y ocupación + + + Tipo de construcción + + + Tipo de cubierta + + + Tipo de ocupación + + + Horario de ocupación + + + p. ej. lun-vie 08:00-17:00, con personal durante la noche + + + Aforo + + + Ocupantes que requieren asistencia + + + Los ocupantes del sitio pueden necesitar asistencia para evacuar + + + Quiénes, dónde y qué asistencia necesitan + + + Notas de ocupación + + + Cortes de suministros + + + Corte de gas + + + Corte eléctrico + + + Corte de agua + + + Notas de suministros + + + Suministro de agua + + + Hidrante más cercano + + + Caudal requerido (GPM) + + + Notas de suministro de agua + + + Acceso + + + Ubicación de la caja de llaves + + + Código de la puerta + + + Se almacena cifrado. Se muestra a los miembros que pueden ver este contacto y a los respondedores de las llamadas vinculadas. + + + Ubicación del panel de alarma + + + Empresa de alarmas + + + Teléfono + + + Notas de acceso + + + Contactos en el sitio + + + Contacto de emergencia + + + Contacto secundario + + + Peligros y tácticas + + + Materiales peligrosos en el sitio + + + Se almacenan o usan materiales peligrosos en el sitio + + + Notas generales de peligros + + + Resumen táctico + + + Ciclo de revisión + + + Última revisión + + + Nunca revisado + + + Marcar este plan previo como revisado ahora + + + Próxima revisión + + + Guardar plan previo + + + Peligros del inmueble + + + Peligros clasificados por tipo y gravedad. Los peligros marcados como alertas se muestran a los despachadores cuando este contacto se agrega a una llamada. + + + Agregar peligro + + + Editar peligro + + + Guardar peligro + + + ¿Quitar este peligro? + + + No se pudo guardar el peligro. + + + El peligro necesita un título. + + + Alertar a los despachadores cuando este contacto se agregue a una llamada + + + Gravedad + + + Título + + + Ubicación + + + Alerta + + + No hay peligros del inmueble registrados. + + + Coordenadas GPS + + + La fecha de revisión del plan previo ya pasó. + + + Revisión de plan previo pendiente + + + Crear plan previo + + + Editar plan previo + + + No se ha registrado un plan previo al incidente para este contacto. + + + No se han adjuntado archivos a este contacto. + + + Subir archivo + + + Imágenes, PDF, documentos de Office, texto, CSV, DWG o DXF de hasta 30 MB. + + + Tipo de archivo + + + Título + + + Título opcional (si se deja en blanco, se usa el nombre del archivo) + + + Nombre del archivo + + + Subir + + + ¿Quitar este archivo? + + + Descargar + + + Tamaño + + + Agregado el + + + No + + + Llamadas + + + Notas + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx index 3f313aba9..3e11efd75 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Ouvrir l'occupation + + Itinéraires + + + Itinéraire + + + Nom de l'arrêt + + + Normale + + + Élevée + + + Critique + + + Optionnelle + + + Plan d'intervention préétabli + + + Plan d'intervention + + + Fichiers du site + + + Gérer les fichiers + + + Construction et occupation + + + Type de construction + + + Type de toiture + + + Type d'occupation + + + Horaires d'occupation + + + p. ex. lun.-ven. 08:00-17:00, personnel présent la nuit + + + Effectif admissible + + + Occupants nécessitant une assistance + + + Des occupants sur place peuvent avoir besoin d'aide pour évacuer + + + Qui, où et quelle assistance est nécessaire + + + Notes sur l'occupation + + + Coupures des réseaux + + + Coupure de gaz + + + Coupure électrique + + + Coupure d'eau + + + Notes sur les réseaux + + + Alimentation en eau + + + Borne la plus proche + + + Débit requis (GPM) + + + Notes sur l'alimentation en eau + + + Accès + + + Emplacement de la boîte à clés + + + Code du portail + + + Stocké chiffré. Affiché aux membres autorisés à consulter ce contact et aux intervenants des appels liés. + + + Emplacement de la centrale d'alarme + + + Société d'alarme + + + Téléphone + + + Notes d'accès + + + Contacts sur place + + + Contact d'urgence + + + Contact secondaire + + + Dangers et tactique + + + Matières dangereuses sur place + + + Des matières dangereuses sont stockées ou utilisées sur place + + + Notes générales sur les dangers + + + Synthèse tactique + + + Cycle de revue + + + Dernière revue + + + Jamais revu + + + Marquer ce plan d'intervention comme revu maintenant + + + Prochaine revue prévue + + + Enregistrer le plan d'intervention + + + Dangers du site + + + Dangers classés par type, avec leur gravité. Les dangers marqués comme alertes sont présentés aux régulateurs lorsque ce contact est ajouté à un appel. + + + Ajouter un danger + + + Modifier le danger + + + Enregistrer le danger + + + Supprimer ce danger ? + + + Le danger n’a pas pu être enregistré. + + + Un danger doit avoir un titre. + + + Alerter les régulateurs lorsque ce contact est ajouté à un appel + + + Gravité + + + Titre + + + Emplacement + + + Alerte + + + Aucun danger du site enregistré. + + + Coordonnées GPS + + + La date de revue du plan d'intervention est dépassée. + + + Revue du plan d'intervention due + + + Créer le plan d'intervention + + + Modifier le plan d'intervention + + + Aucun plan d'intervention préétabli n'a été enregistré pour ce contact. + + + Aucun fichier n'a été joint à ce contact. + + + Téléverser un fichier + + + Images, PDF, documents Office, texte, CSV, DWG ou DXF, jusqu'à 30 Mo. + + + Type de fichier + + + Titre + + + Titre facultatif (nom du fichier par défaut) + + + Nom du fichier + + + Téléverser + + + Supprimer ce fichier ? + + + Télécharger + + + Taille + + + Ajouté le + + + Non + + + Appels + + + Notes + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx index 7ba901b10..653d49207 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Apri occupazione + + Percorsi + + + Percorso + + + Nome fermata + + + Normale + + + Alta + + + Critica + + + Opzionale + + + Piano di pre-intervento + + + Piano di pre-intervento + + + File del sito + + + Gestisci file + + + Costruzione e occupazione + + + Tipo di costruzione + + + Tipo di tetto + + + Tipo di occupazione + + + Orari di occupazione + + + es. lun-ven 08:00-17:00, presidiato di notte + + + Affollamento massimo + + + Occupanti che necessitano assistenza + + + Gli occupanti presenti potrebbero aver bisogno di assistenza per l'evacuazione + + + Chi, dove e quale assistenza serve + + + Note sull'occupazione + + + Interruzione utenze + + + Interruzione gas + + + Interruzione elettrica + + + Interruzione acqua + + + Note sulle utenze + + + Approvvigionamento idrico + + + Idrante più vicino + + + Portata richiesta (GPM) + + + Note sull'approvvigionamento idrico + + + Accesso + + + Posizione della cassetta chiavi + + + Codice cancello + + + Memorizzato in forma crittografata. Visibile ai membri che possono vedere questo contatto e ai soccorritori delle chiamate collegate. + + + Posizione della centrale allarme + + + Società di allarme + + + Telefono + + + Note sull'accesso + + + Contatti in loco + + + Contatto di emergenza + + + Contatto secondario + + + Pericoli e tattica + + + Materiali pericolosi in loco + + + Nel sito vengono stoccati o usati materiali pericolosi + + + Note generali sui pericoli + + + Sintesi tattica + + + Ciclo di revisione + + + Ultima revisione + + + Mai revisionato + + + Segna ora questo piano di pre-intervento come revisionato + + + Prossima revisione prevista + + + Salva piano di pre-intervento + + + Pericoli del sito + + + Pericoli classificati per tipo e gravità. I pericoli contrassegnati come avvisi vengono mostrati agli operatori quando questo contatto viene aggiunto a una chiamata. + + + Aggiungi pericolo + + + Modifica pericolo + + + Salva pericolo + + + Rimuovere questo pericolo? + + + Impossibile salvare il pericolo. + + + Un pericolo deve avere un titolo. + + + Avvisa gli operatori quando questo contatto viene aggiunto a una chiamata + + + Gravità + + + Titolo + + + Posizione + + + Avviso + + + Nessun pericolo del sito registrato. + + + Coordinate GPS + + + La data di revisione del piano di pre-intervento è passata. + + + Revisione pre-intervento da effettuare + + + Crea piano di pre-intervento + + + Modifica piano di pre-intervento + + + Per questo contatto non è stato registrato alcun piano di pre-intervento. + + + Nessun file allegato a questo contatto. + + + Carica file + + + Immagini, PDF, documenti Office, testo, CSV, DWG o DXF fino a 30 MB. + + + Tipo di file + + + Titolo + + + Titolo facoltativo (predefinito: nome del file) + + + Nome del file + + + Carica + + + Rimuovere questo file? + + + Scarica + + + Dimensione + + + Aggiunto il + + + No + + + Chiamate + + + Note + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx index f712893f1..0a11941c8 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Otwórz obiekt + + Trasy + + + Trasa + + + Nazwa przystanku + + + Normalny + + + Wysoki + + + Krytyczny + + + Opcjonalny + + + Plan operacyjny obiektu + + + Plan operacyjny + + + Pliki obiektu + + + Zarządzaj plikami + + + Konstrukcja i użytkowanie + + + Rodzaj konstrukcji + + + Typ dachu + + + Typ obiektu + + + Godziny użytkowania + + + np. pon.–pt. 08:00–17:00, obsada w nocy + + + Liczba użytkowników + + + Osoby wymagające pomocy + + + Osoby przebywające w obiekcie mogą wymagać pomocy przy ewakuacji + + + Kto, gdzie i jakiej pomocy potrzebuje + + + Uwagi o użytkowaniu + + + Odcięcie mediów + + + Zawór gazu + + + Wyłącznik prądu + + + Zawór wody + + + Uwagi o mediach + + + Zaopatrzenie w wodę + + + Najbliższy hydrant + + + Wymagana wydajność (GPM) + + + Uwagi o zaopatrzeniu w wodę + + + Dostęp + + + Lokalizacja skrytki na klucze + + + Kod bramy + + + Przechowywany w postaci zaszyfrowanej. Widoczny dla członków, którzy mogą wyświetlać ten kontakt, oraz dla ratowników w powiązanych zgłoszeniach. + + + Lokalizacja centrali alarmowej + + + Firma alarmowa + + + Telefon + + + Uwagi o dostępie + + + Kontakty w obiekcie + + + Kontakt alarmowy + + + Kontakt dodatkowy + + + Zagrożenia i taktyka + + + Materiały niebezpieczne na miejscu + + + Materiały niebezpieczne są przechowywane lub używane w obiekcie + + + Ogólne uwagi o zagrożeniach + + + Podsumowanie taktyczne + + + Cykl przeglądów + + + Ostatni przegląd + + + Nigdy nie przeglądano + + + Oznacz teraz ten plan operacyjny jako przejrzany + + + Termin następnego przeglądu + + + Zapisz plan operacyjny + + + Zagrożenia na obiekcie + + + Zagrożenia z określonym typem i wagą. Zagrożenia oznaczone jako alerty są pokazywane dyspozytorom, gdy ten kontakt zostanie dodany do zgłoszenia. + + + Dodaj zagrożenie + + + Edytuj zagrożenie + + + Zapisz zagrożenie + + + Usunąć to zagrożenie? + + + Nie udało się zapisać zagrożenia. + + + Zagrożenie musi mieć tytuł. + + + Powiadamiaj dyspozytorów, gdy ten kontakt zostanie dodany do zgłoszenia + + + Waga + + + Tytuł + + + Lokalizacja + + + Alert + + + Brak zarejestrowanych zagrożeń na obiekcie. + + + Współrzędne GPS + + + Termin przeglądu planu operacyjnego minął. + + + Wymagany przegląd planu operacyjnego + + + Utwórz plan operacyjny + + + Edytuj plan operacyjny + + + Dla tego kontaktu nie zarejestrowano planu operacyjnego. + + + Do tego kontaktu nie dołączono żadnych plików. + + + Prześlij plik + + + Obrazy, PDF, dokumenty Office, tekst, CSV, DWG lub DXF do 30 MB. + + + Typ pliku + + + Tytuł + + + Opcjonalny tytuł (domyślnie nazwa pliku) + + + Nazwa pliku + + + Prześlij + + + Usunąć ten plik? + + + Pobierz + + + Rozmiar + + + Dodano + + + Nie + + + Zgłoszenia + + + Notatki + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx index 78a6843d8..1b5b2b6c4 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Öppna objekt + + Rutter + + + Rutt + + + Stoppnamn + + + Normal + + + Hög + + + Kritisk + + + Valfri + + + Insatsplan + + + Insatsplan + + + Platsfiler + + + Hantera filer + + + Byggnad och verksamhet + + + Byggnadstyp + + + Taktyp + + + Objekttyp + + + Verksamhetstider + + + t.ex. mån–fre 08:00–17:00, bemannat nattetid + + + Personantal + + + Personer som behöver hjälp + + + Personer på platsen kan behöva hjälp vid utrymning + + + Vem, var och vilken hjälp som behövs + + + Anteckningar om verksamheten + + + Avstängning av el, gas och vatten + + + Gasavstängning + + + Elavstängning + + + Vattenavstängning + + + Anteckningar om försörjning + + + Vattenförsörjning + + + Närmaste brandpost + + + Erforderligt släckvattenflöde (GPM) + + + Anteckningar om vattenförsörjning + + + Åtkomst + + + Nyckelskåpets placering + + + Grindkod + + + Lagras krypterad. Visas för medlemmar som kan se kontakten och för insatspersonal på länkade larm. + + + Larmcentralens placering + + + Larmbolag + + + Telefon + + + Åtkomstanteckningar + + + Kontakter på plats + + + Nödkontakt + + + Reservkontakt + + + Faror och taktik + + + Farligt gods på plats + + + Farliga ämnen förvaras eller används på platsen + + + Allmänna faroanteckningar + + + Taktisk sammanfattning + + + Granskningscykel + + + Senast granskad + + + Aldrig granskad + + + Markera insatsplanen som granskad nu + + + Nästa granskning senast + + + Spara insatsplan + + + Faror på platsen + + + Faror med typ och allvarlighetsgrad. Faror som markerats som varningar visas för larmoperatörer när kontakten läggs till i ett larm. + + + Lägg till fara + + + Redigera fara + + + Spara fara + + + Ta bort faran? + + + Faran kunde inte sparas. + + + En fara måste ha en titel. + + + Varna larmoperatörer när kontakten läggs till i ett larm + + + Allvarlighet + + + Titel + + + Plats + + + Varning + + + Inga faror på platsen har registrerats. + + + GPS-koordinater + + + Granskningsdatumet för insatsplanen har passerat. + + + Insatsplanen ska granskas + + + Skapa insatsplan + + + Redigera insatsplan + + + Ingen insatsplan har registrerats för den här kontakten. + + + Inga filer har bifogats till den här kontakten. + + + Ladda upp fil + + + Bilder, PDF, Office-dokument, text, CSV, DWG eller DXF på högst 30 MB. + + + Filtyp + + + Titel + + + Valfri titel (filnamnet används som standard) + + + Filnamn + + + Ladda upp + + + Ta bort filen? + + + Ladda ner + + + Storlek + + + Tillagd + + + Nej + + + Larm + + + Anteckningar + diff --git a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx index 865aa7678..f0ac92a90 100644 --- a/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Contacts/Contacts.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Category @@ -441,4 +432,268 @@ Відкрити об'єкт + + Маршрути + + + Маршрут + + + Назва зупинки + + + Звичайний + + + Високий + + + Критичний + + + Необов'язковий + + + План реагування + + + План реагування + + + Файли об'єкта + + + Керування файлами + + + Конструкція та використання + + + Тип конструкції + + + Тип даху + + + Тип об'єкта + + + Години використання + + + напр. Пн–Пт 08:00–17:00, уночі є персонал + + + Місткість + + + Люди, які потребують допомоги + + + Людям на об'єкті може знадобитися допомога під час евакуації + + + Хто, де і якої допомоги потребує + + + Примітки щодо використання + + + Відключення комунікацій + + + Перекриття газу + + + Вимкнення електрики + + + Перекриття води + + + Примітки про комунікації + + + Водопостачання + + + Найближчий гідрант + + + Потрібна витрата води (гал/хв) + + + Примітки про водопостачання + + + Доступ + + + Розташування ключового сейфа + + + Код воріт + + + Зберігається в зашифрованому вигляді. Показується учасникам, які можуть переглядати цей контакт, і учасникам реагування на пов'язані виклики. + + + Розташування панелі сигналізації + + + Охоронна компанія + + + Телефон + + + Примітки щодо доступу + + + Контакти на об'єкті + + + Контакт для екстрених випадків + + + Додатковий контакт + + + Небезпеки й тактика + + + Небезпечні матеріали на об'єкті + + + На об'єкті зберігаються або використовуються небезпечні матеріали + + + Загальні примітки про небезпеки + + + Тактичний підсумок + + + Цикл перегляду + + + Останній перегляд + + + Ще не переглядався + + + Позначити цей план реагування як переглянутий зараз + + + Термін наступного перегляду + + + Зберегти план реагування + + + Небезпеки на об'єкті + + + Небезпеки за типами із зазначенням серйозності. Небезпеки, позначені для сповіщення, показуються диспетчерам, коли цей контакт додається до виклику. + + + Додати небезпеку + + + Редагувати небезпеку + + + Зберегти небезпеку + + + Видалити цю небезпеку? + + + Не вдалося зберегти небезпеку. + + + Вкажіть назву небезпеки. + + + Сповіщати диспетчерів, коли цей контакт додається до виклику + + + Серйозність + + + Назва + + + Місце + + + Сповіщення + + + Небезпек на об'єкті не зафіксовано. + + + GPS-координати + + + Дата перегляду плану реагування минула. + + + Термін перегляду плану реагування + + + Створити план реагування + + + Редагувати план реагування + + + Для цього контакту не зафіксовано плану реагування. + + + До цього контакту не додано жодного файлу. + + + Завантажити файл + + + Зображення, PDF, документи Office, текст, CSV, DWG або DXF розміром до 30 МБ. + + + Тип файлу + + + Назва + + + Необов'язкова назва (за замовчуванням — назва файлу) + + + Назва файлу + + + Завантажити + + + Видалити цей файл? + + + Завантажити + + + Розмір + + + Додано + + + Ні + + + Виклики + + + Нотатки + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.de.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.de.resx new file mode 100644 index 000000000..732e93801 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.de.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Abbrechen + + + Speichern + + + Name + + + Beschreibung + + + Bearbeiten + + + Löschen + + + Importieren + + + Typ + + + Erstellt + + + Reihenfolge + + + Ja + + + Nein + + + + Benutzerdefinierte Karten + + + Benutzerdefinierte Karten + + + Neue benutzerdefinierte Karte + + + Alle + + + Innenraum + + + Außenbereich + + + Veranstaltung + + + Benutzerdefiniert + + + Ebenen + + + Sind Sie sicher, dass Sie diese Karte löschen möchten? + + + + Neue benutzerdefinierte Karte + + + Neue benutzerdefinierte Karte + + + Neu + + + Kartentyp + + + Innenraum + + + Außenbereich + + + Veranstaltung + + + Benutzerdefiniert + + + z. B. Hauptkrankenhaus, Konzertgelände + + + Optionale Beschreibung + + + Begrenzung zeichnen + + + Zeichnen Sie auf der Karte unten ein Rechteck, um die Begrenzung festzulegen. Klicken Sie auf die Karte, um den Mittelpunkt zu setzen. + + + Zeichnen Sie auf der Karte unten ein Rechteck, um die Begrenzung zu aktualisieren. Klicken Sie auf die Karte, um den Mittelpunkt zu setzen. + + + + Benutzerdefinierte Karte bearbeiten + + + Benutzerdefinierte Karte bearbeiten + + + Bearbeiten + + + + Ebenen + + + Ebenen + + + Ebenen + + + Ebene hinzufügen + + + Name der Ebene + + + z. B. Stockwerk 1, Basiskarte, Infrastruktur + + + Reihenfolge + + + Typ + + + Grundriss + + + Überlagerung + + + Datenebene + + + Infrastruktur + + + Ebenenbild + + + Bilder, die größer als 2048 px sind, werden automatisch gekachelt. + + + Ebene hinzufügen + + + Bild vorhanden + + + Gekachelt + + + Bereichseditor + + + Diese Ebene löschen? + + + + Bereichseditor + + + Bereichseditor + + + Bereiche + + + Bereichseigenschaften + + + Name + + + Typ + + + Raum + + + Gebäudeflügel + + + Flur + + + Treppenhaus + + + Aufzug + + + Gefahrenbereich + + + Sammelplatz + + + Bereitstellungsraum + + + Zugangspunkt + + + Versorgungseinrichtung + + + Suchraster + + + Bühne + + + Tor + + + Eingang + + + Parkfläche + + + Verkaufsbereich + + + Rettungsgasse + + + Bezirk + + + Benutzerdefiniert + + + Farbe + + + Beschreibung + + + In der Leitstelle durchsuchbar + + + Disponierbar + + + Bereich speichern + + + Disponierbar + + + + Import + + + Import + + + Import + + + Geodaten importieren + + + Zielebene + + + Datei + + + Unterstützte Formate: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importieren + + + Importverlauf + + + Status + + + Datum + + + Fehler + + + Keine Ebenen verfügbar. Bitte erstellen Sie vor dem Import zuerst eine Ebene. + + + Bitte wählen Sie vor dem Import eine Zielebene aus. Erstellen Sie zuerst eine Ebene, falls noch keine vorhanden ist. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.es.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.es.resx new file mode 100644 index 000000000..1eddf7e05 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.es.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Cancelar + + + Guardar + + + Nombre + + + Descripción + + + Editar + + + Eliminar + + + Importar + + + Tipo + + + Creado + + + Orden + + + Sí + + + No + + + + Mapas personalizados + + + Mapas personalizados + + + Nuevo mapa personalizado + + + Todos + + + Interior + + + Exterior + + + Evento + + + Personalizado + + + Capas + + + ¿Está seguro de que desea eliminar este mapa? + + + + Nuevo mapa personalizado + + + Nuevo mapa personalizado + + + Nuevo + + + Tipo de mapa + + + Interior + + + Exterior + + + Evento + + + Personalizado + + + p. ej. Hospital principal, Recinto de conciertos + + + Descripción opcional + + + Dibujar límites + + + Dibuje un rectángulo en el mapa de abajo para definir los límites. Haga clic en el mapa para establecer el punto central. + + + Dibuje un rectángulo en el mapa de abajo para actualizar los límites. Haga clic en el mapa para establecer el punto central. + + + + Editar mapa personalizado + + + Editar mapa personalizado + + + Editar + + + + Capas + + + Capas + + + Capas + + + Agregar capa + + + Nombre de la capa + + + p. ej. Piso 1, Mapa base, Infraestructura + + + Orden + + + Tipo + + + Plano de planta + + + Superposición + + + Capa de datos + + + Infraestructura + + + Imagen de la capa + + + Las imágenes de más de 2048 px se dividirán automáticamente en mosaicos. + + + Agregar capa + + + Tiene imagen + + + En mosaicos + + + Editor de regiones + + + ¿Eliminar esta capa? + + + + Editor de regiones + + + Editor de regiones + + + Regiones + + + Propiedades de la región + + + Nombre + + + Tipo + + + Sala + + + Ala + + + Pasillo + + + Escalera + + + Elevador + + + Zona de peligro + + + Punto de reunión + + + Área de espera + + + Punto de acceso + + + Cuarto de servicios + + + Cuadrícula de búsqueda + + + Escenario + + + Puerta + + + Entrada + + + Estacionamiento + + + Área de vendedores + + + Carril de emergencia + + + Distrito + + + Personalizado + + + Color + + + Descripción + + + Se puede buscar en el despacho + + + Disponible para despacho + + + Guardar región + + + Disponible para despacho + + + + Importar + + + Importar + + + Importar + + + Importar datos geoespaciales + + + Capa de destino + + + Archivo + + + Formatos admitidos: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importar + + + Historial de importaciones + + + Estado + + + Fecha + + + Error + + + No hay capas disponibles. Cree una capa antes de importar. + + + Seleccione una capa de destino antes de importar. Si no hay ninguna, cree una capa primero. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.fr.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.fr.resx new file mode 100644 index 000000000..da3965553 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.fr.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Annuler + + + Enregistrer + + + Nom + + + Description + + + Modifier + + + Supprimer + + + Importer + + + Type + + + Créée le + + + Ordre + + + Oui + + + Non + + + + Cartes personnalisées + + + Cartes personnalisées + + + Nouvelle carte personnalisée + + + Toutes + + + Intérieures + + + Extérieures + + + Événements + + + Personnalisées + + + Couches + + + Êtes-vous sûr de vouloir supprimer cette carte ? + + + + Nouvelle carte personnalisée + + + Nouvelle carte personnalisée + + + Nouvelle + + + Type de carte + + + Intérieure + + + Extérieure + + + Événement + + + Personnalisée + + + p. ex. Hôpital principal, salle de concert + + + Description facultative + + + Tracer les limites + + + Tracez un rectangle sur la carte ci-dessous pour définir les limites. Cliquez sur la carte pour définir le point central. + + + Tracez un rectangle sur la carte ci-dessous pour mettre à jour les limites. Cliquez sur la carte pour définir le point central. + + + + Modifier la carte personnalisée + + + Modifier la carte personnalisée + + + Modifier + + + + Couches + + + Couches + + + Couches + + + Ajouter une couche + + + Nom de la couche + + + p. ex. Étage 1, fond de carte, infrastructures + + + Ordre + + + Type + + + Plan d'étage + + + Superposition + + + Couche de données + + + Infrastructures + + + Image de la couche + + + Les images de plus de 2048 px seront automatiquement découpées en tuiles. + + + Ajouter la couche + + + Avec image + + + En tuiles + + + Éditeur de régions + + + Supprimer cette couche ? + + + + Éditeur de régions + + + Éditeur de régions + + + Régions + + + Propriétés de la région + + + Nom + + + Type + + + Salle + + + Aile + + + Couloir + + + Cage d'escalier + + + Ascenseur + + + Zone de danger + + + Point de rassemblement + + + Zone de regroupement + + + Point d'accès + + + Local technique + + + Grille de recherche + + + Scène + + + Porte + + + Entrée + + + Zone de stationnement + + + Zone des stands + + + Voie de secours + + + Secteur + + + Personnalisé + + + Couleur + + + Description + + + Recherchable en régulation + + + Utilisable en régulation + + + Enregistrer la région + + + Utilisable en régulation + + + + Importation + + + Importation + + + Importation + + + Importer des données géospatiales + + + Couche cible + + + Fichier + + + Formats pris en charge : GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importer + + + Historique des importations + + + Statut + + + Date + + + Erreur + + + Aucune couche disponible. Veuillez créer une couche avant d'importer. + + + Veuillez sélectionner une couche cible avant d'importer. Créez d'abord une couche s'il n'en existe aucune. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.it.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.it.resx new file mode 100644 index 000000000..1d576299c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.it.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Annulla + + + Salva + + + Nome + + + Descrizione + + + Modifica + + + Elimina + + + Importa + + + Tipo + + + Creata il + + + Ordine + + + Sì + + + No + + + + Mappe personalizzate + + + Mappe personalizzate + + + Nuova mappa personalizzata + + + Tutte + + + Interne + + + Esterne + + + Eventi + + + Personalizzate + + + Livelli + + + Vuoi davvero eliminare questa mappa? + + + + Nuova mappa personalizzata + + + Nuova mappa personalizzata + + + Nuova + + + Tipo di mappa + + + Interna + + + Esterna + + + Evento + + + Personalizzata + + + es. Ospedale principale, Area concerti + + + Descrizione facoltativa + + + Disegna i limiti + + + Disegna un rettangolo sulla mappa qui sotto per definire i limiti. Fai clic sulla mappa per impostare il punto centrale. + + + Disegna un rettangolo sulla mappa qui sotto per aggiornare i limiti. Fai clic sulla mappa per impostare il punto centrale. + + + + Modifica mappa personalizzata + + + Modifica mappa personalizzata + + + Modifica + + + + Livelli + + + Livelli + + + Livelli + + + Aggiungi livello + + + Nome del livello + + + es. Piano 1, Mappa di base, Infrastrutture + + + Ordine + + + Tipo + + + Planimetria + + + Sovrapposizione + + + Livello dati + + + Infrastrutture + + + Immagine del livello + + + Le immagini più grandi di 2048 px verranno suddivise automaticamente in riquadri. + + + Aggiungi livello + + + Con immagine + + + A riquadri + + + Editor regioni + + + Eliminare questo livello? + + + + Editor regioni + + + Editor regioni + + + Regioni + + + Proprietà della regione + + + Nome + + + Tipo + + + Stanza + + + Ala + + + Corridoio + + + Vano scala + + + Ascensore + + + Zona di pericolo + + + Punto di raccolta + + + Area di ammassamento + + + Punto di accesso + + + Area tecnica + + + Griglia di ricerca + + + Palco + + + Cancello + + + Ingresso + + + Area parcheggio + + + Area espositori + + + Corsia di emergenza + + + Distretto + + + Personalizzata + + + Colore + + + Descrizione + + + Ricercabile durante l'invio + + + Disponibile per l'invio + + + Salva regione + + + Disponibile per l'invio + + + + Importa + + + Importa + + + Importa + + + Importa dati geospaziali + + + Livello di destinazione + + + File + + + Formati supportati: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importa + + + Cronologia importazioni + + + Stato + + + Data + + + Errore + + + Nessun livello disponibile. Crea un livello prima di importare. + + + Seleziona un livello di destinazione prima di importare. Se non ce ne sono, crea prima un livello. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.pl.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.pl.resx new file mode 100644 index 000000000..5a639181f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.pl.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Anuluj + + + Zapisz + + + Nazwa + + + Opis + + + Edytuj + + + Usuń + + + Importuj + + + Typ + + + Utworzono + + + Kolejność + + + Tak + + + Nie + + + + Mapy niestandardowe + + + Mapy niestandardowe + + + Nowa mapa niestandardowa + + + Wszystkie + + + Wewnętrzne + + + Zewnętrzne + + + Wydarzenia + + + Niestandardowe + + + Warstwy + + + Czy na pewno chcesz usunąć tę mapę? + + + + Nowa mapa niestandardowa + + + Nowa mapa niestandardowa + + + Nowa + + + Typ mapy + + + Wewnętrzna + + + Zewnętrzna + + + Wydarzenie + + + Niestandardowa + + + np. Szpital główny, Hala koncertowa + + + Opcjonalny opis + + + Narysuj granice + + + Narysuj prostokąt na mapie poniżej, aby określić granice. Kliknij mapę, aby ustawić punkt środkowy. + + + Narysuj prostokąt na mapie poniżej, aby zaktualizować granice. Kliknij mapę, aby ustawić punkt środkowy. + + + + Edytuj mapę niestandardową + + + Edytuj mapę niestandardową + + + Edytuj + + + + Warstwy + + + Warstwy + + + Warstwy + + + Dodaj warstwę + + + Nazwa warstwy + + + np. Piętro 1, Mapa bazowa, Infrastruktura + + + Kolejność + + + Typ + + + Plan piętra + + + Nakładka + + + Warstwa danych + + + Infrastruktura + + + Obraz warstwy + + + Obrazy większe niż 2048 px zostaną automatycznie podzielone na kafelki. + + + Dodaj warstwę + + + Ma obraz + + + Kafelkowana + + + Edytor obszarów + + + Usunąć tę warstwę? + + + + Edytor obszarów + + + Edytor obszarów + + + Obszary + + + Właściwości obszaru + + + Nazwa + + + Typ + + + Pomieszczenie + + + Skrzydło + + + Korytarz + + + Klatka schodowa + + + Winda + + + Strefa zagrożenia + + + Miejsce zbiórki + + + Obszar koncentracji + + + Punkt dostępu + + + Media + + + Siatka poszukiwań + + + Scena + + + Brama + + + Wejście + + + Parking + + + Strefa handlowa + + + Droga ratunkowa + + + Dzielnica + + + Niestandardowy + + + Kolor + + + Opis + + + Wyszukiwalny w dyspozytorni + + + Do dysponowania + + + Zapisz obszar + + + Do dysponowania + + + + Import + + + Import + + + Import + + + Importuj dane geoprzestrzenne + + + Warstwa docelowa + + + Plik + + + Obsługiwane formaty: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importuj + + + Historia importu + + + Status + + + Data + + + Błąd + + + Brak dostępnych warstw. Przed importem utwórz warstwę. + + + Przed importem wybierz warstwę docelową. Jeśli nie ma żadnej, najpierw utwórz warstwę. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.sv.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.sv.resx new file mode 100644 index 000000000..26d604e46 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.sv.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Avbryt + + + Spara + + + Namn + + + Beskrivning + + + Redigera + + + Ta bort + + + Importera + + + Typ + + + Skapad + + + Ordning + + + Ja + + + Nej + + + + Anpassade kartor + + + Anpassade kartor + + + Ny anpassad karta + + + Alla + + + Inomhus + + + Utomhus + + + Evenemang + + + Anpassad + + + Lager + + + Är du säker på att du vill ta bort den här kartan? + + + + Ny anpassad karta + + + Ny anpassad karta + + + Ny + + + Karttyp + + + Inomhus + + + Utomhus + + + Evenemang + + + Anpassad + + + t.ex. Huvudsjukhuset, Konsertarenan + + + Valfri beskrivning + + + Rita gränser + + + Rita en rektangel på kartan nedan för att ange gränserna. Klicka på kartan för att ange mittpunkten. + + + Rita en rektangel på kartan nedan för att uppdatera gränserna. Klicka på kartan för att ange mittpunkten. + + + + Redigera anpassad karta + + + Redigera anpassad karta + + + Redigera + + + + Lager + + + Lager + + + Lager + + + Lägg till lager + + + Lagernamn + + + t.ex. Våning 1, Baskarta, Infrastruktur + + + Ordning + + + Typ + + + Planritning + + + Överlägg + + + Datalager + + + Infrastruktur + + + Lagerbild + + + Bilder större än 2048 px delas automatiskt upp i plattor. + + + Lägg till lager + + + Har bild + + + Plattindelad + + + Områdesredigerare + + + Ta bort lagret? + + + + Områdesredigerare + + + Områdesredigerare + + + Områden + + + Områdesegenskaper + + + Namn + + + Typ + + + Rum + + + Flygel + + + Korridor + + + Trapphus + + + Hiss + + + Riskzon + + + Återsamlingsplats + + + Uppställningsplats + + + Åtkomstpunkt + + + Försörjning + + + Sökruta + + + Scen + + + Grind + + + Ingång + + + Parkeringsområde + + + Försäljningsområde + + + Räddningsväg + + + Distrikt + + + Anpassad + + + Färg + + + Beskrivning + + + Sökbar vid utlarmning + + + Larmbar + + + Spara område + + + Larmbar + + + + Import + + + Import + + + Import + + + Importera geodata + + + Mållager + + + Fil + + + Format som stöds: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Importera + + + Importhistorik + + + Status + + + Datum + + + Fel + + + Det finns inga lager. Skapa ett lager innan du importerar. + + + Välj ett mållager innan du importerar. Skapa först ett lager om det inte finns något. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.uk.resx b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.uk.resx new file mode 100644 index 000000000..5dbe16824 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/CustomMaps/CustomMaps.uk.resx @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Скасувати + + + Зберегти + + + Назва + + + Опис + + + Редагувати + + + Видалити + + + Імпортувати + + + Тип + + + Створено + + + Порядок + + + Так + + + Ні + + + + Власні карти + + + Власні карти + + + Нова власна карта + + + Усі + + + Внутрішні + + + Зовнішні + + + Для заходів + + + Власні + + + Шари + + + Ви впевнені, що хочете видалити цю карту? + + + + Нова власна карта + + + Нова власна карта + + + Нова + + + Тип карти + + + Внутрішня + + + Зовнішня + + + Для заходу + + + Власна + + + напр. Головна лікарня, концертний майданчик + + + Необов'язковий опис + + + Позначення меж + + + Намалюйте прямокутник на карті нижче, щоб визначити межі. Клацніть на карті, щоб задати центральну точку. + + + Намалюйте прямокутник на карті нижче, щоб оновити межі. Клацніть на карті, щоб задати центральну точку. + + + + Редагувати власну карту + + + Редагувати власну карту + + + Редагування + + + + Шари + + + Шари + + + Шари + + + Додати шар + + + Назва шару + + + напр. Поверх 1, Базова карта, Інфраструктура + + + Порядок + + + Тип + + + План поверху + + + Накладення + + + Шар даних + + + Інфраструктура + + + Зображення шару + + + Зображення, більші за 2048 пікс., буде автоматично розбито на тайли. + + + Додати шар + + + Є зображення + + + Розбито на тайли + + + Редактор областей + + + Видалити цей шар? + + + + Редактор областей + + + Редактор областей + + + Області + + + Властивості області + + + Назва + + + Тип + + + Кімната + + + Крило + + + Коридор + + + Сходова клітка + + + Ліфт + + + Небезпечна зона + + + Місце збору + + + Зона зосередження + + + Пункт доступу + + + Комунікації + + + Сітка пошуку + + + Сцена + + + Ворота + + + Вхід + + + Стоянка + + + Торгова зона + + + Проїзд для екстрених служб + + + Район + + + Власна + + + Колір + + + Опис + + + Доступна для пошуку в диспетчеризації + + + Доступна для направлення + + + Зберегти область + + + Для направлення + + + + Імпорт + + + Імпорт + + + Імпорт + + + Імпорт геопросторових даних + + + Цільовий шар + + + Файл + + + Підтримувані формати: GeoJSON (.geojson, .json), KML (.kml), KMZ (.kmz) + + + Імпортувати + + + Історія імпорту + + + Статус + + + Дата + + + Помилка + + + Немає доступних шарів. Перед імпортом спочатку створіть шар. + + + Виберіть цільовий шар перед імпортом. Якщо шарів немає, спочатку створіть шар. + + diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.de.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.de.resx index 425d94a02..a7b23da74 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.de.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.es.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.es.resx index c41761c41..6568efa48 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.es.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.es.resx @@ -315,4 +315,7 @@ Completado + + Tipo base + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.fr.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.fr.resx index 58db0254b..96cc5ec83 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.it.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.it.resx index 91c23e00c..5e049ae54 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.it.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.pl.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.pl.resx index ffd8a6959..faebf4f12 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.sv.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.sv.resx index 7828ffccd..8eeb63d34 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.uk.resx b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.uk.resx index 706456c89..bc7260869 100644 --- a/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/CustomStatuses/CustomStatuses.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Option (Button) diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index a4d06f5fe..46d6c48a6 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -1161,4 +1161,40 @@ لم يتم تعريف أي أدوار للأفراد بعد. + + حالة الوحدة عند توزيع البلاغ + + + حالة الوحدة عند إغلاق البلاغ + + + تستخدم حالات الوحدات الافتراضية هذه أنواع حالات الوحدات المدمجة فقط. استخدم جدول تجاوزات أنواع الوحدات أدناه عندما يحتاج نوع وحدة إلى إحدى حالاته المخصصة. + + + تجاوزات حالات أنواع الوحدات + + + تظهر هنا فقط أنواع الوحدات التي لها حالات وحدات مخصصة. اترك القيمة على "افتراضي" لاستخدام الحالة المدمجة المطبّقة على مستوى القسم أعلاه. + + + الحالات النشطة + + + اتركه فارغًا لاختيار "جميع الحالات". + + + الكل + + + لغة تحويل النص إلى كلام (TTS) + + + اختر لغة أو لهجة eSpeak-NG المستخدمة في الرسائل الصوتية لهذا القسم. + + + اشتراط إعادة تعيين كلمات المرور عبر البريد الإلكتروني + + + يمنع مسؤولي القسم والمجموعات من اختيار كلمة المرور الجديدة للعضو أو الاطلاع عليها. بدلًا من ذلك، تُرسل إجراءات إعادة التعيين إلى العضو رابطًا عبر البريد الإلكتروني قصير الصلاحية يُستخدم لمرة واحدة. تُلغى الجلسات الحالية بعد أن يغيّر العضو كلمة المرور بنجاح. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index 6db3bc110..dc40707ff 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -1112,4 +1103,40 @@ Es sind noch keine Personalrollen definiert. + + Einheitenstatus bei Alarmierung + + + Einheitenstatus bei Freigabe aus dem Einsatz + + + Diese Standard-Einheitenstatus verwenden nur die integrierten Statustypen für Einheiten. Verwenden Sie die Tabelle mit den Überschreibungen nach Einheitentyp unten, wenn ein Einheitentyp einen eigenen benutzerdefinierten Status benötigt. + + + Statusüberschreibungen nach Einheitentyp + + + Hier erscheinen nur Einheitentypen mit benutzerdefinierten Einheitenstatus. Belassen Sie einen Wert auf „Standard“, um den oben festgelegten, abteilungsweit integrierten Status zu verwenden. + + + Aktive Status + + + Leer lassen für „Alle Status“. + + + Alle + + + TTS-Sprache + + + Wählen Sie die eSpeak-NG-Sprache oder den Dialekt für die Sprachansagen dieser Abteilung. + + + Passwortzurücksetzung per E-Mail vorschreiben + + + Verhindert, dass Abteilungs- und Gruppenadministratoren ein neues Passwort für ein Mitglied festlegen oder einsehen. Beim Zurücksetzen erhält das Mitglied stattdessen einen kurzlebigen, einmalig verwendbaren Link per E-Mail. Bestehende Sitzungen werden widerrufen, sobald das Mitglied das Passwort erfolgreich geändert hat. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index ff83df33c..588e0c1cd 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -1191,4 +1191,10 @@ Δεν έχουν οριστεί ακόμη ρόλοι προσωπικού. + + Απαίτηση επαναφοράς κωδικού πρόσβασης μέσω email + + + Εμποδίζει τους διαχειριστές του τμήματος και των ομάδων να επιλέγουν ή να βλέπουν τον νέο κωδικό πρόσβασης ενός μέλους. Αντί γι' αυτό, οι ενέργειες επαναφοράς στέλνουν στο μέλος έναν σύνδεσμο email σύντομης διάρκειας και μίας χρήσης. Οι υπάρχουσες συνεδρίες ανακαλούνται αφού το μέλος αλλάξει επιτυχώς τον κωδικό πρόσβασης. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index 5bb42e958..4989184f4 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -118,19 +118,76 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + Solicite la eliminación de su departamento. Es una operación permanente e irreversible que tarda 25 días en completarse y debe ejecutarse fuera del horario laboral. Durante el período de espera puede cancelar la solicitud si lo desea. + + + Solicitar la eliminación del departamento + + + Su departamento tiene una solicitud de eliminación pendiente. Si desea que la solicitud siga adelante, no tiene que hacer nada: después de la fecha y hora indicadas abajo (durante nuestro proceso nocturno) se eliminarán su departamento y todos sus datos. Si desea cancelar la solicitud y no eliminar el departamento, pulse el botón «Cancelar la eliminación del departamento» que aparece abajo. Tenga en cuenta que cualquier cambio en los administradores o propietarios impedirá que el proceso se complete; se recomienda usar el sistema solo en modo de lectura para descargar los datos que desee conservar. + + + Configuración de mapas y Big Board + + + La configuración de mapas se guardó correctamente. + + + Configuración de mapas del personal + + + Configuración de mapas de las unidades + + + Permitir que un estado sin datos de ubicación oculte la ubicación anterior + + + TTL en minutos para las ubicaciones del personal + + + Permitir que un estado de unidad sin datos de ubicación oculte la ubicación anterior + + + TTL en minutos para las ubicaciones de las unidades + + + Si está habilitado y llega un estado de ese usuario (p. ej., En espera) sin geolocalización, se ocultará la ubicación del usuario en el mapa. De lo contrario, se mostrará la última ubicación del usuario aunque sea antigua. + + + Si está habilitado y llega un estado de esa unidad (p. ej., En el cuartel) sin geolocalización, se ocultará la ubicación de la unidad en el mapa. De lo contrario, se mostrará la última ubicación de la unidad aunque sea antigua. + + + En minutos, durante cuánto tiempo una ubicación es válida para mostrarse en el mapa. Por ejemplo, un valor de 60 significa que el marcador de una persona se mostrará en los mapas hasta 60 minutos y después dejará de mostrarse. Con 0 se desactiva el TTL y cualquier ubicación se mostrará en el mapa, sin importar su antigüedad. + + + En minutos, durante cuánto tiempo una ubicación es válida para mostrarse en el mapa. Por ejemplo, un valor de 60 significa que el marcador de una unidad se mostrará en los mapas hasta 60 minutos y después dejará de mostrarse. Con 0 se desactiva el TTL y cualquier ubicación se mostrará en el mapa, sin importar su antigüedad. - + Configuración de despacho de grupos - + Configuración de despacho de unidades - + Despachar también al personal asignado - + Despachar también a todo el grupo + + + Configuración de turnos + + + La configuración de turnos se guardó correctamente. + + + Configuración de inscripciones en turnos + + + Permitir inscripciones en varios grupos + + + Actualizar el estado del personal de la unidad Propietario de la cuenta @@ -178,7 +235,7 @@ Eliminar invitación - ADVERTENCIA: Esto eliminará permanentemente esta invitación y el enlace que el usuario tiene en el correo electrónico dará como resultado un error. ¿Estás seguro de que quieres eliminar la invitación? + ADVERTENCIA: Esto eliminará permanentemente esta invitación y el enlace que el usuario tiene en el correo electrónico dará como resultado un error. ¿Está seguro de que desea eliminar la invitación? Dirección del departamento @@ -196,16 +253,16 @@ Desactivar automáticamente establecer el estado del personal en el que esté disponible después de una hora - Enviar correo electrónico de importación + Correo electrónico de importación de despachos - Esta es su dirección de correo electrónico de atención, todos los usuarios de su departamento (que tienen notificaciones de llamadas habilitadas) recibirán la alerta de llamadas + Esta es su dirección de correo electrónico de llamada general (ALL-CALL): todos los usuarios de su departamento (que tengan habilitadas las notificaciones de llamadas) recibirán la alerta de la llamada - Configuración de envío + Configuración de despacho - Configuración de envío + Configuración de despacho Dirección de correo electrónico @@ -223,7 +280,7 @@ Actualización del departamento de fuerza - REgrid almacena en caché algunos datos del departamento para aumentar el rendimiento. Algunos ejemplos de datos almacenados en caché son los niveles de personal, la configuración del departamento, los roles de personal \ Groups \ Names. Si tiene problemas en los que algunos datos del departamento son incorrectos, es posible que desee borrar el caché de los departamentos. + Resgrid almacena en caché algunos datos del departamento para mejorar el rendimiento. Algunos ejemplos de datos almacenados en caché son los niveles de dotación, la configuración del departamento y los roles, grupos y nombres del personal. Si algunos datos del departamento no son correctos, puede borrar la caché de su departamento. Invitaciones @@ -250,19 +307,34 @@ Pendiente - Estado de envío de llamadas de persona + Estado de la persona al despachar una llamada - Estado de liberación de llamadas de la persona + Estado de la persona al liberar una llamada + + + Estado de la unidad al despachar una llamada + + + Estado de la unidad al liberar una llamada + + + Estos estados de unidad predeterminados solo usan los tipos de estado de unidad integrados. Use la tabla de anulaciones por tipo de unidad que aparece abajo cuando un tipo de unidad necesite uno de sus propios estados personalizados. + + + Anulaciones de estado por tipo de unidad + + + Aquí solo aparecen los tipos de unidad con estados de unidad personalizados. Deje la opción predeterminada («Default») para usar el estado integrado de todo el departamento indicado arriba. Clasificación de personal - Restablecimiento de personal de personal + Restablecimiento de la dotación del personal - Si necesita restablecer todo el nivel de personal de su personal todos los días, puede configurarlo aquí. Un ejemplo sería restablecer el personal para que no esté disponible todos los días, de modo que para los días de flag roja está seguro de que el personal está marcado disponible después del reinicio. + Si necesita restablecer cada día el nivel de dotación de todo su personal, puede configurarlo aquí. Por ejemplo, puede restablecer la dotación a «No disponible» cada día para que, en los días de alerta roja, tenga la seguridad de que el personal marcado como disponible lo hizo después del restablecimiento. Restablecer el estado del personal @@ -292,13 +364,13 @@ Regenerar URL RSS - Advertencia: la regeneración de sus URL RSS \ Atom requerirá que todos los lectores \ consumidores actualicen a la nueva URL. ¿Estás seguro de que quieres regenerar? + ADVERTENCIA: Si regenera sus URL de RSS/Atom, todos los lectores y consumidores tendrán que actualizarse a la nueva URL. ¿Está seguro de que desea regenerarlas? Reenviar Invitación - ADVERTENCIA: Esto reenviará esta invitación a la dirección de correo electrónico de invitación y restablecerá la fecha enviada. ¿Estás seguro de que quieres reenviar esta invitación? + ADVERTENCIA: Esto reenviará esta invitación a la dirección de correo electrónico invitada y restablecerá la fecha de envío. ¿Está seguro de que desea reenviar esta invitación? Reiniciar personal diariamente? @@ -325,7 +397,7 @@ Su departamento no tiene URL de alimentación RSS aprovisionadas. Haga clic en el botón a continuación para provocar sus URL RSS. Solo elaborar URL RSS \ Atom cuando su departamento lo necesite. - Configurado con éxito la configuración de envío. + La configuración de despacho se guardó correctamente. Enviar invitaciones @@ -334,7 +406,7 @@ Enviado - Establecer el estado para el personal de turno en el envío + Establecer el estado del personal de turno al despachar Configuración de cambio @@ -364,34 +436,91 @@ Use tiempo las 24 horas - Usar turno para el envío grupal + Usar el turno para el despacho de grupos Código postal/postal - + Eliminar departamento - + Aquí puede solicitar la eliminación de su departamento. Solo el propietario de la cuenta puede solicitar la eliminación del departamento. Todos los usuarios y datos asociados a su departamento se eliminarán del sistema Resgrid cuando termine el proceso. ¡Este proceso es irreversible! En cualquier momento antes de que termine el proceso puede cancelar la solicitud y conservar sus datos. Su departamento se eliminará 25 días después de la solicitud. - + Solicitar eliminación - + Marque la casilla siguiente para confirmar su solicitud de eliminación - + Entiendo y acepto el proceso de eliminación - + Cancelar la eliminación del departamento - + Eliminar su departamento + + + Habilitar la supresión por nivel de dotación + + + Suprimir (silenciar) estos niveles de dotación + + + Seleccione abajo los niveles de dotación del personal que no recibirán notificaciones, despachos, alertas, mensajes ni ninguna otra comunicación. Lo más habitual es que sean sus niveles «Fuera de servicio» o «De permiso». + + + Agregar contacto + + + Tipo de contacto + + + Aquí puede controlar la configuración general de cada módulo del sistema Resgrid. Las nuevas opciones generales de módulos se agregarán aquí. Esta configuración está en sus primeras etapas y es posible que todavía no se aplique en todas las partes del sistema Resgrid. Por ejemplo, si deshabilita el módulo de Capacitación, puede seguir apareciendo en una pantalla dentro de otra función. Trabajaremos en el futuro para que sea coherente. + + + Calendario + + + Inventario + + + Bitácoras + + + Mantenimiento + + + Notas + + + Informes + + + Capacitación + + + Cartografía + + + Mensajería + + + Turnos + + + Módulos del departamento + + + Deshabilitar documentos + + + Se guardó la configuración de módulos del departamento - + Cuando una unidad envía un estado y hay usuarios configurados en sus funciones, el estado de esos usuarios se cambiará a «En la unidad» (On Unit). Configuraciones de Llamadas y Despacho @@ -522,6 +651,21 @@ Guardar Configuraciones Generales + + Estados activos + + + Déjelo vacío para usar todos los estados. + + + Todos + + + Idioma de TTS + + + Seleccione el idioma o dialecto de eSpeak-NG que se usará para los mensajes de voz de este departamento. + Ajustes del asistente @@ -829,13 +973,13 @@ Tarjetas de despacho - Todos los mapas de Resgrid y de las aplicaciones se abren aquí. Deja ambos campos vacíos y lo calcularemos a partir de la dirección del departamento al guardar; rellénalos para fijar un punto exacto, que nunca se sobrescribe. + Todos los mapas de Resgrid y de las aplicaciones se abren aquí. Deje ambos campos vacíos y lo calcularemos a partir de la dirección del departamento al guardar; rellénelos para fijar un punto exacto, que nunca se sobrescribe. - Campos del formulario de aviso + Campos del formulario de nueva llamada - Elige qué campos integrados aparecen en el formulario de nuevo aviso y cuáles deben completarse antes de crear el aviso y enviarlo al terreno. Nombre, naturaleza, prioridad y tipo se muestran siempre y son siempre obligatorios. + Elija qué campos integrados aparecen en el formulario de nueva llamada y cuáles deben completarse antes de crear la llamada y enviarla al terreno. Nombre, naturaleza, prioridad y tipo se muestran siempre y son siempre obligatorios. Campo @@ -874,7 +1018,7 @@ Datos de contacto del informante - ID externo del aviso + ID externo de la llamada ID del incidente @@ -886,7 +1030,7 @@ Protocolos - Aviso vinculado + Llamada vinculada Despacho programado @@ -898,7 +1042,7 @@ Temporizadores de estado de unidad - Resalta una unidad en el Big Board cuando lleva demasiado tiempo en un estado — por ejemplo, despachada más de cuatro minutos sin informar de que ha salido. Deja ambas columnas en 0 para no controlar nada. + Resalta una unidad en el Big Board cuando lleva demasiado tiempo en un estado — por ejemplo, despachada más de cuatro minutos sin informar de que ha salido. Deje ambas columnas en 0 para no controlar nada. Estado @@ -936,6 +1080,12 @@ Regresando + + Exigir el restablecimiento de contraseñas por correo electrónico + + + Impide que los administradores del departamento y de grupo elijan o vean la nueva contraseña de un miembro. En su lugar, las acciones de restablecimiento envían al miembro un enlace de un solo uso y de corta duración por correo electrónico. Las sesiones existentes se revocan cuando el miembro cambia la contraseña correctamente. + Perfil del departamento diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 0f7c4d471..badbe611c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -1112,4 +1103,40 @@ Aucun rôle de personnel n'est encore défini. + + Statut de l'unité à l'engagement + + + Statut de l'unité à la libération + + + Ces statuts d'unité par défaut n'utilisent que les types d'état d'unité intégrés. Utilisez le tableau de remplacements par type d'unité ci-dessous lorsqu'un type d'unité a besoin de l'un de ses propres statuts personnalisés. + + + Remplacements de statut par type d'unité + + + Seuls les types d'unité disposant de statuts d'unité personnalisés apparaissent ici. Laissez une valeur sur « Par défaut » pour utiliser le statut intégré défini ci-dessus pour tout le département. + + + États concernés + + + Laissez vide pour « Tous les états ». + + + Tous + + + Langue de synthèse vocale + + + Sélectionnez la langue ou le dialecte eSpeak-NG à utiliser pour les messages vocaux de ce département. + + + Exiger la réinitialisation des mots de passe par e-mail + + + Empêche les administrateurs de département et de groupe de choisir ou de voir le nouveau mot de passe d'un membre. Les actions de réinitialisation envoient à la place au membre un lien par e-mail à usage unique et de courte durée. Les sessions existantes sont révoquées une fois que le membre a modifié son mot de passe avec succès. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index e304ba210..592d5cfa5 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -423,7 +414,7 @@ Inventario - Registri + Log Maintenance @@ -1005,7 +996,7 @@ Profilo del dipartimento - Questa è l'identità che Resgrid stampa sui rapporti, mostra nelle e-mail personalizzate e usa ovunque il dipartimento si presenti. Lasciate un campo vuoto per ricorrere alle impostazioni del dipartimento. + Questa è l'identità che Resgrid stampa sui registri, mostra nelle e-mail personalizzate e usa ovunque il dipartimento si presenti. Lasciate un campo vuoto per ricorrere alle impostazioni del dipartimento. Identità @@ -1032,7 +1023,7 @@ Indirizzo - L'indirizzo stampato su rapporti ed e-mail è quello del dipartimento: + L'indirizzo stampato su registri ed e-mail è quello del dipartimento: Social @@ -1112,4 +1103,40 @@ Non sono ancora definiti ruoli del personale. + + Stato dell'unità all'invio della chiamata + + + Stato dell'unità al rilascio dalla chiamata + + + Questi stati predefiniti delle unità usano solo i tipi di stato integrati. Usa la tabella di sovrascrittura per tipo di unità qui sotto quando un tipo di unità ha bisogno di uno dei propri stati personalizzati. + + + Sovrascritture di stato per tipo di unità + + + Qui compaiono solo i tipi di unità con stati personalizzati. Lascia il valore su Predefinito per usare lo stato integrato valido per tutto il dipartimento indicato sopra. + + + Stati attivi + + + Lascia vuoto per "Tutti gli stati". + + + Tutti + + + Lingua TTS + + + Seleziona la lingua o il dialetto eSpeak-NG da usare per i messaggi vocali di questo dipartimento. + + + Richiedi il ripristino della password tramite e-mail + + + Impedisce agli amministratori del dipartimento e dei gruppi di scegliere o vedere la nuova password di un membro. Le azioni di ripristino inviano invece al membro un link via e-mail monouso e di breve durata. Le sessioni esistenti vengono chiuse dopo che il membro ha cambiato la password. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index 64ae26c37..b37857237 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -1112,4 +1103,40 @@ Nie zdefiniowano jeszcze żadnych ról personelu. + + Status jednostki przy zadysponowaniu do zgłoszenia + + + Status jednostki przy zwolnieniu ze zgłoszenia + + + Te domyślne statusy jednostek korzystają wyłącznie z wbudowanych typów stanów jednostek. Jeśli typ jednostki potrzebuje jednego ze swoich niestandardowych statusów, użyj poniższej tabeli nadpisań dla typów jednostek. + + + Nadpisania statusów dla typów jednostek + + + Widoczne są tu tylko typy jednostek z niestandardowymi statusami jednostek. Pozostaw wartość „Domyślny”, aby użyć ustawionego powyżej wbudowanego statusu obowiązującego w całym dziale. + + + Aktywne stany + + + Pozostaw puste, aby wybrać „Wszystkie stany”. + + + Wszystkie + + + Język TTS + + + Wybierz język lub dialekt eSpeak-NG używany w komunikatach głosowych tego działu. + + + Wymagaj resetowania haseł przez e-mail + + + Uniemożliwia administratorom działu i grup wybieranie lub oglądanie nowego hasła członka. Zamiast tego akcje resetowania wysyłają członkowi krótkotrwały, jednorazowy link e-mail. Istniejące sesje są unieważniane, gdy członek pomyślnie zmieni hasło. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index b83c7d0f0..0850263f5 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -1112,4 +1103,40 @@ Inga personalroller har definierats ännu. + + Enhetsstatus vid utlarmning + + + Enhetsstatus vid frisläppning från larm + + + Dessa standardstatusar för enheter använder endast de inbyggda typerna av enhetsstatus. Använd tabellen med åsidosättningar per enhetstyp nedan när en enhetstyp behöver en egen anpassad status. + + + Statusåsidosättningar per enhetstyp + + + Endast enhetstyper med anpassade enhetsstatusar visas här. Låt värdet stå på Standard för att använda den inbyggda status för hela avdelningen som anges ovan. + + + Aktiva statusar + + + Lämna tomt för ”Alla statusar”. + + + Alla + + + TTS-språk + + + Välj det eSpeak-NG-språk eller den dialekt som ska användas för avdelningens röstmeddelanden. + + + Kräv lösenordsåterställning via e-post + + + Hindrar avdelnings- och gruppadministratörer från att välja eller se en medlems nya lösenord. Vid återställning skickas i stället en kortlivad engångslänk till medlemmen via e-post. Befintliga sessioner återkallas när medlemmen har bytt lösenord. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index 337c48155..54705d326 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Request deletion of your department. This is a permanent and non-reversable operation that takes 25 days to complete and needs to run after hours. During the wait period you can cancel the request if you choose. @@ -1112,4 +1103,40 @@ Ролі персоналу ще не визначено. + + Статус одиниці під час направлення на виклик + + + Статус одиниці після звільнення з виклику + + + Ці стандартні статуси одиниць використовують лише вбудовані типи станів одиниць. Якщо типу одиниці потрібен один із його власних статусів, скористайтеся таблицею перевизначень за типом одиниці нижче. + + + Перевизначення статусів за типом одиниці + + + Тут відображаються лише типи одиниць із власними статусами одиниць. Залиште значення «За замовчуванням», щоб використовувати вбудований статус для всього підрозділу, указаний вище. + + + Активні стани + + + Залиште порожнім для варіанта «Усі стани». + + + Усі + + + Мова TTS + + + Виберіть мову або діалект eSpeak-NG для голосових підказок цього підрозділу. + + + Вимагати скидання пароля через електронну пошту + + + Не дає адміністраторам підрозділу та груп вибирати або бачити новий пароль учасника. Натомість під час скидання учаснику надсилається електронною поштою короткочасне одноразове посилання. Наявні сеанси відкликаються після того, як учасник успішно змінить пароль. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.de.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.de.resx index ba6a8298a..37248dc81 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.es.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.es.resx index 4fdb1b6af..101298fd8 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.es.resx @@ -98,4 +98,196 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Agregar un nuevo tipo de llamada + + + Prioridades de llamada + + + Sonido de alerta + + + Predeterminada + + + Tiene habilitadas prioridades de llamada personalizadas. Para volver a las predeterminadas, deberá eliminar todas las personalizadas. Al editar una prioridad de llamada existente, se actualizará en todas las llamadas que la usaron. + + + Actualmente usa las prioridades de llamada predeterminadas. Si agrega prioridades de llamada personalizadas, ya no podrá utilizar las predeterminadas (incluidos sus sonidos). Si no especifica un sonido personalizado para sus nuevas prioridades de llamada, usarán el sonido de notificación push y el sonido de alerta de las llamadas de prioridad alta. Los usuarios de la versión 5 o anterior de la aplicación Responder y de la versión 2 o anterior de la aplicación Unit no admiten prioridades de llamada personalizadas. Si tiene usuarios con esas versiones de las aplicaciones, le recomendamos no crear prioridades de llamada personalizadas. + + + Ícono de mapa del tipo de llamada + + + Ícono de mapa + + + Nombre del tipo de llamada + + + Nombre del tipo de llamada + + + Nombre del tipo + + + Tipos de llamada + + + Nombre de la certificación + + + Nombre de la certificación + + + ADVERTENCIA: Esto eliminará permanentemente este tipo de certificación. ¿Está seguro de que desea eliminar el tipo de certificación + + + Tipos de certificación + + + Nombre del tipo + + + Acciones de unidad predeterminadas + + + ADVERTENCIA: Esto eliminará permanentemente este tipo de llamada. ¿Está seguro de que desea eliminar el tipo de llamada + + + ADVERTENCIA: Esto eliminará permanentemente esta categoría de documentos. ¿Está seguro de que desea eliminar la categoría + + + ADVERTENCIA: Esto eliminará permanentemente este tipo de nota. ¿Está seguro de que desea eliminar el tipo de nota + + + ADVERTENCIA: Esto eliminará permanentemente este tipo de unidad. ¿Está seguro de que desea eliminar el tipo de unidad + + + Tipos de departamento + + + Nombre de la categoría + + + Nombre de la nueva categoría (tipo) de documento + + + Categorías de documentos + + + Editar prioridad de llamada + + + Editar tipo de llamada + + + Editar tipo de unidad + + + Administrar el orden de las listas + + + Agregar una nueva prioridad de llamada + + + Nueva prioridad de llamada + + + Nuevo tipo de llamada + + + Nuevo tipo de certificación + + + Nueva categoría de documentos + + + Nueva categoría de documentos + + + Nuevo tipo de nota + + + Nuevo tipo de unidad + + + Nombre del tipo de nota (categoría) + + + Tipos de nota + + + Tipo de nota + + + Color de la prioridad + + + Predeterminada + + + Nombre de la prioridad + + + Nombre de la prioridad + + + Despachar personal + + + Orden + + + Sonido de alerta + + + Despachar unidades + + + Acciones (estados de unidad personalizados) + + + Ícono de mapa + + + Nombre del tipo de unidad + + + Nombre del tipo de unidad + + + Tipos de unidad + + + Tipos de nota de contacto + + + Agregar nuevo tipo de nota de contacto + + + Nuevo tipo de nota de contacto + + + Nombre del tipo de nota de contacto + + + ¿Está seguro de que desea eliminar el tipo de nota de contacto llamado + + + Nombre del tipo de nota de contacto + + + El nombre del tipo de nota de contacto + + + Color del tipo de nota de contacto + + + Editar tipo de nota de contacto + + + Nuevo tipo de nota de contacto + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.fr.resx index eb0fef2c2..fc281e69f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.it.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.it.resx index 731c1cf22..0f5a6df52 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.pl.resx index 067e21ccd..61f86eddf 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.sv.resx index a720c35e2..899c58b0c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.uk.resx index 552ac029a..c8c29845d 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/DepartmentTypes.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add a new call type diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index aacdf68b3..2e8837a8a 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -377,4 +377,25 @@ لا يوجد موقع + + معلومات الموقع + + + لا توجد جهات اتصال ذات معلومات موقع مرتبطة بهذا البلاغ. + + + خطر في المنشأة + + + مخاطر المنشأة + + + ملاحظات التنبيه + + + لم تُسجَّل خطة ما قبل الحادث لجهة الاتصال هذه. + + + ملفات الموقع + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 15c40a731..f92529de1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Keine Position + + Objektinformationen + + + Mit diesem Einsatz sind keine Kontakte mit Objektinformationen verknüpft. + + + Objektgefahr + + + Objektgefahren + + + Warnhinweise + + + Für diesen Kontakt wurde kein Einsatzplan erfasst. + + + Objektdateien + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx index 559bec648..d8c06fdb7 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx @@ -847,4 +847,25 @@ Χωρίς θέση + + Πληροφορίες Χώρου + + + Καμία επαφή με πληροφορίες χώρου δεν είναι συνδεδεμένη με αυτή την κλήση. + + + Κίνδυνος Χώρου + + + Κίνδυνοι Χώρου + + + Σημειώσεις Ειδοποίησης + + + Δεν έχει καταχωριστεί προσχέδιο επέμβασης για αυτή την επαφή. + + + Αρχεία Χώρου + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index bc4f4e02c..7124b454b 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -993,4 +993,25 @@ Sin posición + + Información del sitio + + + No hay contactos con información del sitio vinculados a esta llamada. + + + Peligro del inmueble + + + Peligros del inmueble + + + Notas de alerta + + + No se ha registrado un plan previo al incidente para este contacto. + + + Archivos del sitio + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 19aa97bc5..c26812b4e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Aucune position + + Informations sur le site + + + Aucun contact disposant d'informations sur le site n'est lié à cet appel. + + + Danger du site + + + Dangers du site + + + Notes d'alerte + + + Aucun plan d'intervention préétabli n'a été enregistré pour ce contact. + + + Fichiers du site + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index 4ee8df1f4..230cbc4d3 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Nessuna posizione + + Informazioni sul sito + + + Nessun contatto con informazioni sul sito è collegato a questa chiamata. + + + Pericolo del sito + + + Pericoli del sito + + + Note di avviso + + + Per questo contatto non è stato registrato alcun piano di pre-intervento. + + + File del sito + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index 49b04b20e..aec59fdaf 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Brak pozycji + + Informacje o obiekcie + + + Z tym zgłoszeniem nie powiązano żadnych kontaktów z informacjami o obiekcie. + + + Zagrożenie na obiekcie + + + Zagrożenia na obiekcie + + + Notatki z alertem + + + Dla tego kontaktu nie zarejestrowano planu operacyjnego. + + + Pliki obiektu + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index fda4a1592..c4aa96369 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Ingen position + + Platsinformation + + + Inga kontakter med platsinformation är länkade till det här larmet. + + + Fara på platsen + + + Faror på platsen + + + Varningsanteckningar + + + Ingen insatsplan har registrerats för den här kontakten. + + + Platsfiler + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index af8ca9b8a..c369cd43d 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls @@ -944,4 +935,25 @@ Немає позиції + + Інформація про об'єкт + + + До цього виклику не прив'язано контактів з інформацією про об'єкт. + + + Небезпека на об'єкті + + + Небезпеки на об'єкті + + + Нотатки зі сповіщенням + + + Для цього контакту не зафіксовано плану реагування. + + + Файли об'єкта + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx index 9c263a174..9ab61a802 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx index 185cb0de1..9dc18ef19 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx index b30a3dd85..b53c02a9c 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx index dc7636901..9f48b738f 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx index f4a0ce61b..f335c4a36 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx index 9d6a30f38..1c2654497 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Dashboard.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Active Calls diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.de.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.de.resx index 29dec70fc..a840e9791 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.es.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.es.resx index 1af7de150..82dc6e53c 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.es.resx @@ -117,4 +117,49 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Solo administradores + + + Categoría + + + Eliminar + + + ADVERTENCIA: Esto eliminará permanentemente este documento. ¿Está seguro de que desea eliminar el documento? + + + Editar documento + + + Todos + + + Imágenes + + + Nuevo documento + + + Nombre del documento + + + Presentaciones + + + Hojas de cálculo + + + Subir + + + ¡No se permiten todos los tipos de archivo! El archivo debe pesar menos de 10 MB y tener una de estas extensiones (.png, .jpg, .jpeg, .gif, .pdf, .doc, .docx, .txt, .ppt, .pptx, .pps, .ppsx, .odt, .xls, .xlsx, .mp4, .mp3) + + + Visible por + + + Ver documento + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.fr.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.fr.resx index f09e540e5..09d229697 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.it.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.it.resx index 44062e4ed..39e173fd1 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.pl.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.pl.resx index fbaaf758e..5fdf00452 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.sv.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.sv.resx index c0dc81be9..63ad6ac96 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Documents/Documents.uk.resx b/Core/Resgrid.Localization/Areas/User/Documents/Documents.uk.resx index 02a6d8244..423f2b6a0 100644 --- a/Core/Resgrid.Localization/Areas/User/Documents/Documents.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Documents/Documents.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx index 52605c656..44a64ff17 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.ar.resx @@ -131,4 +131,7 @@ رمز PIN من 4 أرقام + + جارٍ إجراء مكالمة تحقق. يُرجى الرد وتدوين الرمز الذي سيُتلى عليك. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx index 7d124f621..0c2dbf600 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ 4-stellige PIN + + + + + Ein Bestätigungsanruf wird durchgeführt. Bitte nehmen Sie den Anruf an und notieren Sie sich den angesagten Code. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx index d3a00f62b..fe263d084 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.es.resx @@ -447,4 +447,13 @@ PIN de 4 dígitos + + Eliminar su cuenta + + + Elimine su propia cuenta de Resgrid en todos los departamentos de los que es miembro + + + Se está realizando una llamada de verificación. Conteste y anote el código que se le dicte. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx index 5681f628c..15901d1d2 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ PIN à 4 chiffres + + + + + Un appel de vérification est en cours. Veuillez répondre et noter le code qui vous sera communiqué. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx index 622e68192..901a37c90 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ PIN a 4 cifre + + + + + È in corso una chiamata di verifica. Rispondi e annota il codice che ti viene comunicato. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx index fd0cc96c1..5f3383f59 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ 4-cyfrowy PIN + + + + + Trwa wykonywanie połączenia weryfikacyjnego. Odbierz telefon i zanotuj podyktowany kod. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx index 55e4489aa..acb29c36a 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ 4-siffrig PIN + + + + + Vi ringer upp dig med ett verifieringssamtal. Svara och notera koden som läses upp. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx index b64b515e3..fa7a4eb51 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/EditProfile.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Account Information @@ -401,4 +392,10 @@ 4-значний PIN + + + + + Здійснюється дзвінок для підтвердження. Будь ласка, відповідайте та запишіть продиктований вам код. + diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.de.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.de.resx index c6c764580..44b82dea3 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.de.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Notiz zur Aktion (optional) Aktionen - Are you sure you want to reset all personnel status to Available? + Möchten Sie den Status des gesamten Personals wirklich auf „Verfügbar“ zurücksetzen? - Collapse Groups (Once) + Gruppen einklappen (einmalig) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + BESTÄTIGEN: Möchten Sie wirklich alle Benutzer der Gruppe auf den Status „Bereitschaft“ zurücksetzen? Gruppe: - Confirm Status Reset + Zurücksetzen des Status bestätigen - Department Code: + Abteilungscode: - Department Id: + Abteilungs-ID: - Department Info + Abteilungsinformationen - Reset all to Available + Alle auf „Verfügbar“ zurücksetzen - Reset group to Standing By + Gruppe auf „Bereitschaft“ zurücksetzen - Reset Status + Status zurücksetzen - Set Current Action + Aktuelle Aktion festlegen - Set Staffing Level + Bereitschaftsstufe festlegen - Staffing Level + Bereitschaftsstufe - Set Staffing Level + Bereitschaftsstufe festlegen - Text Number: + SMS-Nummer: - Toggle Collapse Groups (Saves) + Gruppen ein-/ausklappen (wird gespeichert) diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.fr.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.fr.resx index 4688d9277..c4fe88a87 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.fr.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Note sur l'action (facultatif) Actions - Are you sure you want to reset all personnel status to Available? + Voulez-vous vraiment réinitialiser le statut de tout le personnel sur « Disponible » ? - Collapse Groups (Once) + Réduire les groupes (une fois) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + CONFIRMER : voulez-vous vraiment réinitialiser tous les utilisateurs du groupe à l'état « En attente » ? Groupe : - Confirm Status Reset + Confirmer la réinitialisation du statut - Department Code: + Code du département : - Department Id: + ID du département : - Department Info + Informations sur le département - Reset all to Available + Tout réinitialiser sur « Disponible » - Reset group to Standing By + Réinitialiser le groupe sur « En attente » - Reset Status + Réinitialiser le statut - Set Current Action + Définir l'action en cours - Set Staffing Level + Définir le niveau de disponibilité - Staffing Level + Niveau de disponibilité - Set Staffing Level + Définir le niveau de disponibilité - Text Number: + Numéro SMS : - Toggle Collapse Groups (Saves) + Réduire/développer les groupes (enregistré) diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.it.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.it.resx index 575cc8db6..2fada85b9 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.it.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Nota sull'azione (facoltativa) Azioni - Are you sure you want to reset all personnel status to Available? + Vuoi davvero reimpostare lo stato di tutto il personale su "Disponibile"? - Collapse Groups (Once) + Comprimi i gruppi (una volta) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + CONFERMA: vuoi davvero reimpostare tutti gli utenti del gruppo sullo stato "In attesa"? Gruppo: - Confirm Status Reset + Conferma reimpostazione dello stato - Department Code: + Codice del dipartimento: - Department Id: + ID dipartimento: - Department Info + Informazioni sul dipartimento - Reset all to Available + Reimposta tutti su "Disponibile" - Reset group to Standing By + Reimposta il gruppo su "In attesa" - Reset Status + Reimposta stato - Set Current Action + Imposta l'azione corrente - Set Staffing Level + Imposta il livello di disponibilità - Staffing Level + Livello di disponibilità - Set Staffing Level + Imposta il livello di disponibilità - Text Number: + Numero SMS: - Toggle Collapse Groups (Saves) + Comprimi/espandi i gruppi (viene salvato) diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.pl.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.pl.resx index a36e29c20..e2f32cec6 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.pl.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Notatka do akcji (opcjonalnie) Akcje - Are you sure you want to reset all personnel status to Available? + Czy na pewno chcesz zresetować status całego personelu do "Dostępny"? - Collapse Groups (Once) + Zwiń grupy (jednorazowo) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + POTWIERDŹ: Czy na pewno chcesz zresetować wszystkich użytkowników grupy do stanu "W gotowości"? Grupa: - Confirm Status Reset + Potwierdź reset statusu - Department Code: + Kod oddziału: - Department Id: + ID oddziału: - Department Info + Informacje o oddziale - Reset all to Available + Zresetuj wszystkich do "Dostępny" - Reset group to Standing By + Zresetuj grupę do "W gotowości" - Reset Status + Resetuj status - Set Current Action + Ustaw bieżącą akcję - Set Staffing Level + Ustaw poziom gotowości - Staffing Level + Poziom gotowości - Set Staffing Level + Ustaw poziom gotowości - Text Number: + Numer SMS: - Toggle Collapse Groups (Saves) + Zwiń/rozwiń grupy (zapisywane) diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.sv.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.sv.resx index 8323d8b6d..2722718de 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.sv.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Anteckning till åtgärden (valfritt) Åtgärder - Are you sure you want to reset all personnel status to Available? + Vill du verkligen återställa statusen för all personal till "Tillgänglig"? - Collapse Groups (Once) + Fäll ihop grupper (en gång) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + BEKRÄFTA: Vill du verkligen återställa alla användare i gruppen till statusen "Beredskap"? Grupp: - Confirm Status Reset + Bekräfta återställning av status - Department Code: + Avdelningskod: - Department Id: + Avdelnings-ID: - Department Info + Avdelningsinformation - Reset all to Available + Återställ alla till "Tillgänglig" - Reset group to Standing By + Återställ gruppen till "Beredskap" - Reset Status + Återställ status - Set Current Action + Ange aktuell åtgärd - Set Staffing Level + Ange beredskapsnivå - Staffing Level + Beredskapsnivå - Set Staffing Level + Ange beredskapsnivå - Text Number: + SMS-nummer: - Toggle Collapse Groups (Saves) + Fäll ihop/ut grupper (sparas) diff --git a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.uk.resx b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.uk.resx index 1169627a5..3481756de 100644 --- a/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Home/HomeDashboard.uk.resx @@ -59,67 +59,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Action Note (Optional) + Примітка до дії (необов'язково) Дії - Are you sure you want to reset all personnel status to Available? + Ви дійсно бажаєте скинути статус усього персоналу на "Доступний"? - Collapse Groups (Once) + Згорнути групи (одноразово) - CONFIRM: Are you sure you want to reset all the users group to the Standing By state? Group: + ПІДТВЕРДЖЕННЯ: Ви дійсно бажаєте скинути всіх користувачів групи до стану "В очікуванні"? Група: - Confirm Status Reset + Підтвердьте скидання статусу - Department Code: + Код підрозділу: - Department Id: + ID підрозділу: - Department Info + Інформація про підрозділ - Reset all to Available + Скинути всіх на "Доступний" - Reset group to Standing By + Скинути групу до "В очікуванні" - Reset Status + Скинути статус - Set Current Action + Встановити поточну дію - Set Staffing Level + Встановити рівень готовності - Staffing Level + Рівень готовності - Set Staffing Level + Встановити рівень готовності - Text Number: + Номер для SMS: - Toggle Collapse Groups (Saves) + Згорнути/розгорнути групи (зберігається) diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.de.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.de.resx new file mode 100644 index 000000000..2ce90b0b4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.de.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Abbrechen + + + Speichern + + + Name + + + Beschreibung + + + Bearbeiten + + + Löschen + + + Erstellt + + + Ja + + + Nein + + + + Innenraumkarten + + + Innenraumkarten + + + Neue Innenraumkarte + + + Stockwerke + + + Sind Sie sicher, dass Sie diese Innenraumkarte löschen möchten? + + + + Neue Innenraumkarte + + + Neue Innenraumkarte + + + Neu + + + z. B. Hauptkrankenhaus + + + Optionale Beschreibung + + + Breitengrad des Mittelpunkts + + + Längengrad des Mittelpunkts + + + z. B. 39.7392 + + + z. B. -104.9903 + + + NO-Grenze Br. + + + NO-Grenze Lg. + + + SW-Grenze Br. + + + SW-Grenze Lg. + + + + Innenraumkarte bearbeiten + + + Innenraumkarte bearbeiten + + + Bearbeiten + + + + Stockwerke + + + Stockwerke + + + Stockwerk hinzufügen + + + Name des Stockwerks + + + z. B. Stockwerk 1, Keller, Dach + + + Reihenfolge + + + Grundrissbild + + + Stockwerk hinzufügen + + + Stockwerke + + + Bild vorhanden + + + Zoneneditor + + + Dieses Stockwerk löschen? + + + + Zoneneditor + + + Zoneneditor + + + Zonen + + + Zoneneigenschaften + + + Name + + + Typ + + + Raum + + + Gebäudeflügel + + + Flur + + + Treppenhaus + + + Aufzug + + + Gefahrenbereich + + + Sammelplatz + + + Bereitstellungsraum + + + Zugangspunkt + + + Versorgungseinrichtung + + + Suchraster + + + Benutzerdefiniert + + + Farbe + + + Beschreibung + + + In der Leitstelle durchsuchbar + + + Zone speichern + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.es.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.es.resx new file mode 100644 index 000000000..1c111eb8e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.es.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Cancelar + + + Guardar + + + Nombre + + + Descripción + + + Editar + + + Eliminar + + + Creado + + + Sí + + + No + + + + Mapas de interiores + + + Mapas de interiores + + + Nuevo mapa de interiores + + + Pisos + + + ¿Está seguro de que desea eliminar este mapa de interiores? + + + + Nuevo mapa de interiores + + + Nuevo mapa de interiores + + + Nuevo + + + p. ej. Hospital principal + + + Descripción opcional + + + Latitud del centro + + + Longitud del centro + + + p. ej. 39.7392 + + + p. ej. -104.9903 + + + Lat. límite NE + + + Lon. límite NE + + + Lat. límite SO + + + Lon. límite SO + + + + Editar mapa de interiores + + + Editar mapa de interiores + + + Editar + + + + Pisos + + + Pisos + + + Agregar piso + + + Nombre del piso + + + p. ej. Piso 1, Sótano, Azotea + + + Orden + + + Imagen del plano de planta + + + Agregar piso + + + Pisos + + + Tiene imagen + + + Editor de zonas + + + ¿Eliminar este piso? + + + + Editor de zonas + + + Editor de zonas + + + Zonas + + + Propiedades de la zona + + + Nombre + + + Tipo + + + Sala + + + Ala + + + Pasillo + + + Escalera + + + Elevador + + + Zona de peligro + + + Punto de reunión + + + Área de espera + + + Punto de acceso + + + Cuarto de servicios + + + Cuadrícula de búsqueda + + + Personalizado + + + Color + + + Descripción + + + Se puede buscar en el despacho + + + Guardar zona + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.fr.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.fr.resx new file mode 100644 index 000000000..f9e499624 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.fr.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Annuler + + + Enregistrer + + + Nom + + + Description + + + Modifier + + + Supprimer + + + Créée le + + + Oui + + + Non + + + + Cartes intérieures + + + Cartes intérieures + + + Nouvelle carte intérieure + + + Étages + + + Êtes-vous sûr de vouloir supprimer cette carte intérieure ? + + + + Nouvelle carte intérieure + + + Nouvelle carte intérieure + + + Nouvelle + + + p. ex. Hôpital principal + + + Description facultative + + + Latitude du centre + + + Longitude du centre + + + p. ex. 39.7392 + + + p. ex. -104.9903 + + + Lat. limite NE + + + Lon. limite NE + + + Lat. limite SO + + + Lon. limite SO + + + + Modifier la carte intérieure + + + Modifier la carte intérieure + + + Modifier + + + + Étages + + + Étages + + + Ajouter un étage + + + Nom de l'étage + + + p. ex. Étage 1, sous-sol, toit + + + Ordre + + + Image du plan d'étage + + + Ajouter l'étage + + + Étages + + + Avec image + + + Éditeur de zones + + + Supprimer cet étage ? + + + + Éditeur de zones + + + Éditeur de zones + + + Zones + + + Propriétés de la zone + + + Nom + + + Type + + + Salle + + + Aile + + + Couloir + + + Cage d'escalier + + + Ascenseur + + + Zone de danger + + + Point de rassemblement + + + Zone de regroupement + + + Point d'accès + + + Local technique + + + Grille de recherche + + + Personnalisé + + + Couleur + + + Description + + + Recherchable en régulation + + + Enregistrer la zone + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.it.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.it.resx new file mode 100644 index 000000000..fa3d3170c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.it.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Annulla + + + Salva + + + Nome + + + Descrizione + + + Modifica + + + Elimina + + + Creata il + + + Sì + + + No + + + + Mappe interne + + + Mappe interne + + + Nuova mappa interna + + + Piani + + + Vuoi davvero eliminare questa mappa interna? + + + + Nuova mappa interna + + + Nuova mappa interna + + + Nuova + + + es. Ospedale principale + + + Descrizione facoltativa + + + Latitudine del centro + + + Longitudine del centro + + + es. 39.7392 + + + es. -104.9903 + + + Lat. limite NE + + + Lon. limite NE + + + Lat. limite SO + + + Lon. limite SO + + + + Modifica mappa interna + + + Modifica mappa interna + + + Modifica + + + + Piani + + + Piani + + + Aggiungi piano + + + Nome del piano + + + es. Piano 1, Seminterrato, Tetto + + + Ordine + + + Immagine della planimetria + + + Aggiungi piano + + + Piani + + + Con immagine + + + Editor zone + + + Eliminare questo piano? + + + + Editor zone + + + Editor zone + + + Zone + + + Proprietà della zona + + + Nome + + + Tipo + + + Stanza + + + Ala + + + Corridoio + + + Vano scala + + + Ascensore + + + Zona di pericolo + + + Punto di raccolta + + + Area di ammassamento + + + Punto di accesso + + + Locale tecnico + + + Griglia di ricerca + + + Personalizzata + + + Colore + + + Descrizione + + + Ricercabile durante l'invio + + + Salva zona + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.pl.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.pl.resx new file mode 100644 index 000000000..04cb4910a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.pl.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Anuluj + + + Zapisz + + + Nazwa + + + Opis + + + Edytuj + + + Usuń + + + Utworzono + + + Tak + + + Nie + + + + Mapy wewnętrzne + + + Mapy wewnętrzne + + + Nowa mapa wewnętrzna + + + Piętra + + + Czy na pewno chcesz usunąć tę mapę wewnętrzną? + + + + Nowa mapa wewnętrzna + + + Nowa mapa wewnętrzna + + + Nowa + + + np. Szpital główny + + + Opcjonalny opis + + + Szerokość geograficzna środka + + + Długość geograficzna środka + + + np. 39.7392 + + + np. -104.9903 + + + Szer. granicy NE + + + Dług. granicy NE + + + Szer. granicy SW + + + Dług. granicy SW + + + + Edytuj mapę wewnętrzną + + + Edytuj mapę wewnętrzną + + + Edytuj + + + + Piętra + + + Piętra + + + Dodaj piętro + + + Nazwa piętra + + + np. Piętro 1, Piwnica, Dach + + + Kolejność + + + Obraz planu piętra + + + Dodaj piętro + + + Piętra + + + Ma obraz + + + Edytor stref + + + Usunąć to piętro? + + + + Edytor stref + + + Edytor stref + + + Strefy + + + Właściwości strefy + + + Nazwa + + + Typ + + + Pomieszczenie + + + Skrzydło + + + Korytarz + + + Klatka schodowa + + + Winda + + + Strefa zagrożenia + + + Miejsce zbiórki + + + Obszar koncentracji + + + Punkt dostępu + + + Media + + + Siatka poszukiwań + + + Niestandardowa + + + Kolor + + + Opis + + + Wyszukiwalna w dyspozytorni + + + Zapisz strefę + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.sv.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.sv.resx new file mode 100644 index 000000000..acff9165d --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.sv.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Avbryt + + + Spara + + + Namn + + + Beskrivning + + + Redigera + + + Ta bort + + + Skapad + + + Ja + + + Nej + + + + Inomhuskartor + + + Inomhuskartor + + + Ny inomhuskarta + + + Våningar + + + Är du säker på att du vill ta bort den här inomhuskartan? + + + + Ny inomhuskarta + + + Ny inomhuskarta + + + Ny + + + t.ex. Huvudsjukhuset + + + Valfri beskrivning + + + Mittpunktens latitud + + + Mittpunktens longitud + + + t.ex. 39.7392 + + + t.ex. -104.9903 + + + NO-gräns lat + + + NO-gräns lon + + + SV-gräns lat + + + SV-gräns lon + + + + Redigera inomhuskarta + + + Redigera inomhuskarta + + + Redigera + + + + Våningar + + + Våningar + + + Lägg till våning + + + Våningsnamn + + + t.ex. Våning 1, Källare, Tak + + + Ordning + + + Bild av planritning + + + Lägg till våning + + + Våningar + + + Har bild + + + Zonredigerare + + + Ta bort våningen? + + + + Zonredigerare + + + Zonredigerare + + + Zoner + + + Zonegenskaper + + + Namn + + + Typ + + + Rum + + + Flygel + + + Korridor + + + Trapphus + + + Hiss + + + Riskzon + + + Återsamlingsplats + + + Uppställningsplats + + + Åtkomstpunkt + + + Försörjning + + + Sökruta + + + Anpassad + + + Färg + + + Beskrivning + + + Sökbar vid utlarmning + + + Spara zon + + diff --git a/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.uk.resx b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.uk.resx new file mode 100644 index 000000000..ef23062f4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/IndoorMaps/IndoorMaps.uk.resx @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Скасувати + + + Зберегти + + + Назва + + + Опис + + + Редагувати + + + Видалити + + + Створено + + + Так + + + Ні + + + + Карти приміщень + + + Карти приміщень + + + Нова карта приміщення + + + Поверхи + + + Ви впевнені, що хочете видалити цю карту приміщення? + + + + Нова карта приміщення + + + Нова карта приміщення + + + Нова + + + напр. Головна лікарня + + + Необов'язковий опис + + + Широта центру + + + Довгота центру + + + напр. 39.7392 + + + напр. -104.9903 + + + Широта ПнСх межі + + + Довгота ПнСх межі + + + Широта ПдЗх межі + + + Довгота ПдЗх межі + + + + Редагувати карту приміщення + + + Редагувати карту приміщення + + + Редагування + + + + Поверхи + + + Поверхи + + + Додати поверх + + + Назва поверху + + + напр. Поверх 1, Підвал, Дах + + + Порядок + + + Зображення плану поверху + + + Додати поверх + + + Поверхи + + + Є зображення + + + Редактор зон + + + Видалити цей поверх? + + + + Редактор зон + + + Редактор зон + + + Зони + + + Властивості зони + + + Назва + + + Тип + + + Кімната + + + Крило + + + Коридор + + + Сходова клітка + + + Ліфт + + + Небезпечна зона + + + Місце збору + + + Зона зосередження + + + Пункт доступу + + + Комунікації + + + Сітка пошуку + + + Власна + + + Колір + + + Опис + + + Доступна для пошуку в диспетчеризації + + + Зберегти зону + + diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx index 0f4b80c99..274446494 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx @@ -663,4 +663,9 @@ غير نشط هل يوجد مورّد غير مدرج؟ أضفه من صفحة المورّدين. اختر عملة هذا الطلب. تستخدم الطلبات الجديدة عملة القسم افتراضيًا. + إضافة نوع مخزون + تعديل المخزون + الدفعة\الرقم التسلسلي + إضافة نوع + تعديل نوع المخزون diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx index 209edb68f..e7b3beb9f 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.ar.resx @@ -31,30 +31,58 @@ معلومات الطب الشرعي معلومات سجل العمل الوحدات - المحطة السبب - دورة التدريب - نوع التدريب - الساعات نوع الاجتماع - موقع الاجتماع - الأسلوب - الجنس - العمر - العرق - نوع العمل - موقع العمل سجل تشغيل جديد - تعديل سجل التشغيل - تاريخ البدء - تاريخ الانتهاء - تاريخ السجل - الإجراءات - إضافة أفراد إلى السجل - لا يوجد أفراد - لا توجد وحدات - إغلاق - حفظ - إلغاء السجلات مفعّلة لهذه الإدارة. السجلات القديمة للقراءة فقط: يمكن عرضها وطباعتها وتصديرها لكن لا يمكن إنشاؤها أو تعديلها أو حذفها. + موقع الجثة مطلوب. + طابع وقت البلاغ + رقم القضية + الوضع + الطب الشرعي + التاريخ + موقع الوجهة + مُرسَل + المحقق + الموقع + معلومات السجل + السجلات لعام + الاجتماع + وقت انتهاء الاجتماع مطلوب. + موقع الاجتماع مطلوب. + وقت بدء الاجتماع مطلوب. + نوع الاجتماع مطلوب. + بلاغ جديد + يمكنك أدناه إضافة سجل جديد إلى النظام (مثل تقرير تشغيل، أو أعمال المحطة، أو تدريب، أو فعالية، وغير ذلك). يمكن استخدام السجلات لتوثيق تفاصيل الاستجابة للبلاغات والاجتماعات والتدريبات. الحقول المكتوبة بخط مائل أزرق مطلوبة. + حاضرون آخرون + آخرون لامسوا الجثة + يتطلب تحديد الأفراد + رئيس (رؤساء) الاجتماع + أُعلنت الوفاة بواسطة + حقل "أُعلنت الوفاة بواسطة" مطلوب. + التاريخ مطلوب. + اختيار بلاغ + الضابط المسؤول الأقدم (OIC) + وقت انتهاء التدريب مطلوب. + وقت بدء التدريب مطلوب. + أفراد الوحدة + الوحدات والأفراد + وقت انتهاء العمل مطلوب. + وقت بدء العمل مطلوب. + سُجّل في + سُجّل بواسطة + رقم البلاغ + تفاصيل السجل + الوضع / التقرير الأولي + غير معيّنين لأي وحدة + طباعة / تصدير + عرض الطباعة (التصدير) + حذف السجل + هل أنت متأكد أنك تريد حذف هذا السجل؟ لا يمكن التراجع عن هذا الإجراء. + تقرير السجل + تم الإنشاء + المعرّف الخارجي + بدون نوع + غير محدد + رئيس الاجتماع diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx index 3dfc05a46..26a6d310b 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx index 678d206d7..43078d614 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.es.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Agregar unidad al registro + Agregar unidad a la bitácora Archivos adjuntos @@ -129,12 +129,18 @@ Se requiere la ubicación del cuerpo. + + Dirección de la llamada (ubicación) + Nombre de llamada Prioridad de llamadas + + Fecha y hora de la llamada + Caso # @@ -145,13 +151,13 @@ Condición - Juez de instrucción + Médico forense Fecha - ADVERTENCIA: Esto eliminará permanentemente este registro. ¿Estás seguro de que quieres eliminar el registro? + ADVERTENCIA: Esto eliminará permanentemente esta bitácora. ¿Está seguro de que desea eliminar la bitácora? Ubicación de destino @@ -160,19 +166,19 @@ Enviada - Investigadora + Investigador Ubicación - Información de registro + Información de la bitácora - Registros para el año + Bitácoras del año - Registro + Bitácoras Reunión @@ -195,17 +201,20 @@ Narrativa + + Naturaleza de la llamada + Nueva llamada - Nuevo registro + Nueva bitácora - Nuevo registro + Nueva bitácora - A continuación puede agregar un nuevo registro al sistema (es decir, el informe de ejecución, el trabajo de la estación, la capacitación, el evento, etc.). Los registros se utilizarán para detallar la información sobre las ejecuciones de llamadas, reuniones y entrenamientos. Se requieren campos en cursiva azul. + A continuación puede agregar una nueva bitácora al sistema (por ejemplo, un informe de intervención, trabajo en la estación, una capacitación o un evento). Las bitácoras sirven para detallar la información sobre intervenciones, reuniones y capacitaciones. Los campos en cursiva azul son obligatorios. Otros asistentes @@ -220,13 +229,13 @@ requerido el personal - Personas presidiadas + Persona(s) que preside(n) - Pronunciado fallecido por + Declarado fallecido por - Se requiere pronunciado fallecido por. + Se requiere indicar quién declaró el fallecimiento. Se requiere fecha. @@ -235,7 +244,7 @@ Seleccionar llamada - Senior (OIC) + Oficial a cargo (OIC) Se requiere tiempo de finalización de capacitación. @@ -259,91 +268,91 @@ Se requiere la hora de inicio del trabajo. - View Log + Ver bitácora - Back to Logs + Volver a las bitácoras - General Information + Información general - Log Summary + Resumen de la bitácora - Log ID + ID de la bitácora - Log Type + Tipo de bitácora - Logged On + Fecha de registro - Logged By + Registrado por - Duration + Duración - Call Information + Información de la llamada - Call Number + Número de llamada - View Full Call + Ver la llamada completa - Training Information + Información de la capacitación - Meeting Information + Información de la reunión - Coroner Information + Información forense - Log Details + Detalles de la bitácora - Condition / Initial Report + Condición / Informe inicial - Not Assigned to a Unit + No asignado a una unidad - Print / Export + Imprimir / Exportar - Print (Export) View + Vista de impresión (exportación) - Delete Log + Eliminar bitácora - Are you sure you want to delete this log? This action cannot be undone. + ¿Está seguro de que desea eliminar esta bitácora? Esta acción no se puede deshacer. - Log Report + Informe de bitácora - Generated + Generado - External ID + ID externo - No Type + Sin tipo - Not Supplied + No proporcionado - Presiding + Presidido por - Work Log Information + Información de la bitácora de trabajo Registros está activo para este departamento. Las bitácoras existentes son de solo lectura: se pueden ver, imprimir y exportar, pero no crear, editar ni eliminar. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx index d0f3ca194..537956624 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx index 0ce460aca..ee0538f1b 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log @@ -129,7 +120,7 @@ Logs for Year - Registri + Log Meeting diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx index 1503e0f21..7c9cd65fd 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx index 05ed6cb9a..2647c62e3 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log diff --git a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx index 80fb60019..0031a3699 100644 --- a/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Logs/Logs.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Unit to Log diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx new file mode 100644 index 000000000..a5dfcf2ef --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.de.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Kartierung + + + Ebenen verwalten + + + Benutzerdefinierte Karten + + + Kartenoptionen + + + Einsätze anzeigen + + + Personal anzeigen + + + Einheiten anzeigen + + + Stationen anzeigen + + + Geofences anzeigen + + + POIs anzeigen + + + Schließen + + + Speichern + + + Abbrechen + + + + POI hinzufügen + + + POI hinzufügen + + + Breitengrad + + + Breitengrad (Dezimalschreibweise, z. B. 39.1517) + + + Längengrad + + + Längengrad (Dezimalschreibweise, z. B. -119.4571) + + + Notiz + + + + POI-Typ hinzufügen + + + POI-Typ hinzufügen + + + Name + + + Ist Ziel + + + Kennzeichnet ein mögliches Ziel für eine Einheit oder Person, z. B. ein Büro, einen Sammelpunkt, ein Krankenhaus oder einen Zugangspunkt. + + + Farbe + + + Markierung + + + Symbol + + + Keine + + + + Ebene bearbeiten + + + Ebene bearbeiten + + + Neue Ebene + + + Neue Ebene + + + Standardmäßig sichtbar + + + Soll diese Ebene bei jedem Laden einer Karte sichtbar sein? Hinweis: Zu viele standardmäßig sichtbare Ebenen machen die Karte unübersichtlich. Benutzer können Ebenen in der Kartensteuerung einblenden, wenn sie sie sehen möchten. + + + Durchsuchbar + + + Damit kann die Adresssuche Koordinaten aus dieser Ebene einbeziehen. Hinweis: Wenn Sie keine durchsuchbaren Punkte (Textfelder) haben, markieren Sie die Ebene nicht als durchsuchbar, da dies die Alarmierung verlangsamen kann. + + + Ebenenelemente + + + Ebene bearbeiten + + + Ebene hinzufügen + + + + POIs importieren + + + POIs importieren + + + Hochladen + + + POIs importieren + + + + Ebenen + + + Ebenen + + + Neue Ebene + + + Anzahl Elemente + + + Bearbeiten + + + Löschen + + + + Live-Navigation + + + Live-Routenführung + + + + POIs + + + POIs + + + POI-Typ hinzufügen + + + Anzahl Punkte + + + Ansehen + + + Hinzufügen + + + Importieren + + + WARNUNG: Dadurch werden dieser POI-Typ und alle seine Positionen dauerhaft gelöscht. Möchten Sie den POI-Typ + + + wirklich löschen? + + + + Stationsnavigation + + + Routenführung zur Station + + + Entfernung: + + + Dauer: + + + + Typ ansehen + + + Typ ansehen + + + Karte + + + Informationen + + + Liste + + + Ja + + + Nein + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx new file mode 100644 index 000000000..93ad95a18 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.es.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Cartografía + + + Administrar capas + + + Mapas personalizados + + + Opciones del mapa + + + Mostrar llamadas + + + Mostrar personal + + + Mostrar unidades + + + Mostrar estaciones + + + Mostrar geocercas + + + Mostrar PDI + + + Cerrar + + + Guardar + + + Cancelar + + + + Agregar PDI + + + Agregar PDI + + + Latitud + + + Latitud (Notación decimal: p. ej. 39.1517) + + + Longitud + + + Longitud (Notación decimal: p. ej. -119.4571) + + + Nota + + + + Agregar tipo de PDI + + + Agregar tipo de PDI + + + Nombre + + + Es destino + + + Para indicar un posible destino de una unidad o persona. Por ejemplo, una oficina, un punto de reunión, un hospital, un punto de acceso, etc. + + + Color + + + Marcador + + + Ícono + + + Ninguno + + + + Editar capa + + + Editar capa + + + Nueva capa + + + Nueva capa + + + Visible por defecto + + + ¿Desea que esta capa sea visible cada vez que se carga un mapa? Nota: tener demasiadas capas visibles por defecto complicará el mapa. Los usuarios pueden activar las capas que quieran ver desde el control del mapa. + + + Incluir en búsquedas + + + Esto permitirá que la búsqueda de direcciones use las coordenadas de esta capa. Nota: si no tiene puntos que se puedan buscar (campos de texto), no active esta opción, ya que puede ralentizar el despacho. + + + Elementos de la capa + + + Editar capa + + + Agregar capa + + + + Importar PDI + + + Importar PDI + + + Subir + + + Importar PDI + + + + Capas + + + Capas + + + Nueva capa + + + Cantidad de elementos + + + Editar + + + Eliminar + + + + Navegación en vivo + + + Rutas en vivo + + + + PDI + + + PDI + + + Agregar tipo de PDI + + + Cantidad de puntos + + + Ver + + + Agregar + + + Importar + + + ADVERTENCIA: Esto eliminará permanentemente este tipo de PDI y todas sus posiciones. ¿Está seguro de que desea eliminar el tipo de PDI + + + ? + + + + Navegación a la estación + + + Ruta a la estación + + + Distancia: + + + Duración: + + + + Ver tipo + + + Ver tipo + + + Mapa + + + Información + + + Lista + + + Sí + + + No + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx new file mode 100644 index 000000000..cd160c847 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.fr.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Cartographie + + + Gérer les couches + + + Cartes personnalisées + + + Options de la carte + + + Afficher les appels + + + Afficher le personnel + + + Afficher les unités + + + Afficher les stations + + + Afficher les géorepérages + + + Afficher les POI + + + Fermer + + + Enregistrer + + + Annuler + + + + Ajouter un POI + + + Ajouter un POI + + + Latitude + + + Latitude (notation décimale : p. ex. 39.1517) + + + Longitude + + + Longitude (notation décimale : p. ex. -119.4571) + + + Note + + + + Ajouter un type de POI + + + Ajouter un type de POI + + + Nom + + + Est une destination + + + Indique une destination potentielle pour une unité ou une personne, par exemple un bureau, un point de ralliement, un hôpital, un point d'accès, etc. + + + Couleur + + + Marqueur + + + Icône + + + Aucun + + + + Modifier la couche + + + Modifier la couche + + + Nouvelle couche + + + Nouvelle couche + + + Visible par défaut + + + Voulez-vous que cette couche soit visible à chaque chargement d'une carte ? Remarque : un trop grand nombre de couches visibles par défaut surchargera la carte. Les utilisateurs peuvent activer les couches qu'ils souhaitent voir depuis le contrôle de la carte. + + + Recherchable + + + Permet à la recherche d'adresses d'utiliser les coordonnées de cette couche. Remarque : si vous n'avez pas de points recherchables (champs texte), ne cochez pas cette option, car elle peut ralentir la régulation. + + + Éléments de la couche + + + Modifier la couche + + + Ajouter la couche + + + + Importer des POI + + + Importer des POI + + + Téléverser + + + Importer les POI + + + + Couches + + + Couches + + + Nouvelle couche + + + Nombre d'éléments + + + Modifier + + + Supprimer + + + + Navigation en direct + + + Itinéraire en direct + + + + POI + + + POI + + + Ajouter un type de POI + + + Nombre de points + + + Voir + + + Ajouter + + + Importer + + + AVERTISSEMENT : cette action supprimera définitivement ce type de POI et toutes ses positions. Êtes-vous sûr de vouloir supprimer le type de POI + + + ? + + + + Navigation vers la station + + + Itinéraire vers la station + + + Distance : + + + Durée : + + + + Voir le type + + + Voir le type + + + Carte + + + Informations + + + Liste + + + Oui + + + Non + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx new file mode 100644 index 000000000..b2eb9835a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.it.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Mappatura + + + Gestisci livelli + + + Mappe personalizzate + + + Opzioni mappa + + + Mostra chiamate + + + Mostra personale + + + Mostra unità + + + Mostra stazioni + + + Mostra geofence + + + Mostra POI + + + Chiudi + + + Salva + + + Annulla + + + + Aggiungi POI + + + Aggiungi POI + + + Latitudine + + + Latitudine (notazione decimale, es. 39.1517) + + + Longitudine + + + Longitudine (notazione decimale, es. -119.4571) + + + Nota + + + + Aggiungi tipo di POI + + + Aggiungi tipo di POI + + + Nome + + + È una destinazione + + + Indica una possibile destinazione per un'unità o una persona, ad esempio un ufficio, un punto di raccolta, un ospedale, un punto di accesso, ecc. + + + Colore + + + Marcatore + + + Icona + + + Nessuna + + + + Modifica livello + + + Modifica livello + + + Nuovo livello + + + Nuovo livello + + + Visibile per impostazione predefinita + + + Vuoi che questo livello sia visibile ogni volta che si carica una mappa? Nota: troppi livelli visibili per impostazione predefinita rendono la mappa confusa. Gli utenti possono attivare i livelli che vogliono vedere dal controllo della mappa. + + + Ricercabile + + + Consente alla ricerca degli indirizzi di usare le coordinate di questo livello. Nota: se non hai punti ricercabili (campi di testo), non contrassegnare il livello come ricercabile, perché può rallentare l'invio. + + + Elementi del livello + + + Modifica livello + + + Aggiungi livello + + + + Importa POI + + + Importa POI + + + Carica + + + Importa POI + + + + Livelli + + + Livelli + + + Nuovo livello + + + Numero di elementi + + + Modifica + + + Elimina + + + + Navigazione in tempo reale + + + Percorso in tempo reale + + + + POI + + + POI + + + Aggiungi tipo di POI + + + Numero di punti + + + Visualizza + + + Aggiungi + + + Importa + + + ATTENZIONE: l'operazione è definitiva. Vuoi davvero eliminare il tipo di POI + + + e tutte le sue posizioni? + + + + Navigazione verso la stazione + + + Percorso verso la stazione + + + Distanza: + + + Durata: + + + + Visualizza tipo + + + Visualizza tipo + + + Mappa + + + Informazioni + + + Elenco + + + Vero + + + Falso + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx new file mode 100644 index 000000000..8c256a52f --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.pl.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Mapowanie + + + Zarządzaj warstwami + + + Mapy niestandardowe + + + Opcje mapy + + + Pokaż zgłoszenia + + + Pokaż personel + + + Pokaż jednostki + + + Pokaż stacje + + + Pokaż geofence + + + Pokaż POI + + + Zamknij + + + Zapisz + + + Anuluj + + + + Dodaj POI + + + Dodaj POI + + + Szerokość geograficzna + + + Szerokość geograficzna (zapis dziesiętny, np. 39.1517) + + + Długość geograficzna + + + Długość geograficzna (zapis dziesiętny, np. -119.4571) + + + Notatka + + + + Dodaj typ POI + + + Dodaj typ POI + + + Nazwa + + + Punkt docelowy + + + Oznacza potencjalny cel dla jednostki lub osoby, np. biuro, miejsce zbiórki, szpital, punkt dostępu itp. + + + Kolor + + + Znacznik + + + Ikona + + + Brak + + + + Edytuj warstwę + + + Edytuj warstwę + + + Nowa warstwa + + + Nowa warstwa + + + Widoczna domyślnie + + + Czy ta warstwa ma być widoczna przy każdym wczytaniu mapy? Uwaga: zbyt wiele warstw widocznych domyślnie sprawi, że mapa stanie się nieczytelna. Użytkownicy mogą włączyć warstwy w kontrolce mapy, jeśli chcą je zobaczyć. + + + Wyszukiwalna + + + Pozwala wyszukiwaniu adresów korzystać ze współrzędnych z tej warstwy. Uwaga: jeśli nie masz punktów, które można wyszukiwać (pól tekstowych), nie oznaczaj warstwy jako wyszukiwalnej, ponieważ może to spowolnić dysponowanie. + + + Elementy warstwy + + + Edytuj warstwę + + + Dodaj warstwę + + + + Importuj POI + + + Importuj POI + + + Prześlij plik + + + Importuj POI + + + + Warstwy + + + Warstwy + + + Nowa warstwa + + + Liczba elementów + + + Edytuj + + + Usuń + + + + Nawigacja na żywo + + + Wyznaczanie trasy na żywo + + + + POI + + + POI + + + Dodaj typ POI + + + Liczba punktów + + + Wyświetl + + + Dodaj + + + Importuj + + + OSTRZEŻENIE: spowoduje to trwałe usunięcie tego typu POI i wszystkich jego pozycji. Czy na pewno chcesz usunąć + + + (typ POI)? + + + + Nawigacja do stacji + + + Trasa do stacji + + + Odległość: + + + Czas trwania: + + + + Wyświetl typ + + + Wyświetl typ + + + Mapa + + + Informacje + + + Lista + + + Tak + + + Nie + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx new file mode 100644 index 000000000..5241f4887 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.sv.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Kartläggning + + + Hantera lager + + + Anpassade kartor + + + Kartalternativ + + + Visa larm + + + Visa personal + + + Visa enheter + + + Visa stationer + + + Visa geofences + + + Visa POI + + + Stäng + + + Spara + + + Avbryt + + + + Lägg till POI + + + Lägg till POI + + + Latitud + + + Latitud (decimalform, t.ex. 39.1517) + + + Longitud + + + Longitud (decimalform, t.ex. -119.4571) + + + Anteckning + + + + Lägg till POI-typ + + + Lägg till POI-typ + + + Namn + + + Är destination + + + Markerar en möjlig destination för en enhet eller person, till exempel ett kontor, en samlingsplats, ett sjukhus eller en åtkomstpunkt. + + + Färg + + + Markör + + + Ikon + + + Ingen + + + + Redigera lager + + + Redigera lager + + + Nytt lager + + + Nytt lager + + + Synligt som standard + + + Vill du att lagret ska vara synligt varje gång en karta läses in? Obs! För många lager som är synliga som standard gör kartan rörig. Användarna kan slå på lager i kartkontrollen om de vill se dem. + + + Sökbart + + + Detta gör att adressökningen kan använda koordinater från det här lagret. Obs! Markera inte lagret som sökbart om det saknar sökbara punkter (textfält), eftersom det kan göra utlarmningen långsammare. + + + Lagerelement + + + Redigera lager + + + Lägg till lager + + + + Importera POI + + + Importera POI + + + Ladda upp + + + Importera POI + + + + Lager + + + Lager + + + Nytt lager + + + Antal objekt + + + Redigera + + + Ta bort + + + + Navigering i realtid + + + Ruttplanering i realtid + + + + POI + + + POI + + + Lägg till POI-typ + + + Antal punkter + + + Visa + + + Lägg till + + + Importera + + + VARNING: Detta tar bort POI-typen och alla dess positioner. Åtgärden kan inte ångras. Vill du verkligen ta bort POI-typen + + + för gott? + + + + Navigering till station + + + Ruttplanering till station + + + Avstånd: + + + Restid: + + + + Visa typ + + + Visa typ + + + Karta + + + Information + + + Lista + + + Ja + + + Nej + + diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx new file mode 100644 index 000000000..61aa96be4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Mapping/Mapping.uk.resx @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Картографування + + + Керування шарами + + + Власні карти + + + Параметри карти + + + Показувати виклики + + + Показувати персонал + + + Показувати одиниці + + + Показувати станції + + + Показувати геозони + + + Показувати POI + + + Закрити + + + Зберегти + + + Скасувати + + + + Додати POI + + + Додати POI + + + Широта + + + Широта (десятковий формат, напр. 39.1517) + + + Довгота + + + Довгота (десятковий формат, напр. -119.4571) + + + Нотатка + + + + Додати тип POI + + + Додати тип POI + + + Назва + + + Пункт призначення + + + Позначає можливий пункт призначення для одиниці чи особи, наприклад офіс, місце збору, лікарню, пункт доступу тощо. + + + Колір + + + Маркер + + + Значок + + + Немає + + + + Редагувати шар + + + Редагувати шар + + + Новий шар + + + Новий шар + + + Видимий за замовчуванням + + + Показувати цей шар щоразу під час завантаження карти? Примітка: забагато шарів, видимих за замовчуванням, перевантажать карту. Користувачі можуть самі ввімкнути потрібні шари в елементі керування картою. + + + Доступний для пошуку + + + Дозволяє пошуку адрес використовувати координати з цього шару. Примітка: якщо у вас немає точок, придатних для пошуку (текстових полів), не позначайте шар як доступний для пошуку, оскільки це може сповільнити диспетчеризацію. + + + Елементи шару + + + Редагувати шар + + + Додати шар + + + + Імпорт POI + + + Імпорт POI + + + Завантажити + + + Імпортувати POI + + + + Шари + + + Шари + + + Новий шар + + + Кількість елементів + + + Редагувати + + + Видалити + + + + Навігація в реальному часі + + + Маршрут у реальному часі + + + + POI + + + POI + + + Додати тип POI + + + Кількість точок + + + Переглянути + + + Додати + + + Імпортувати + + + ПОПЕРЕДЖЕННЯ: це остаточно видалить цей тип POI і всі його позиції. Ви впевнені, що хочете видалити + + + (тип POI)? + + + + Навігація до станції + + + Маршрут до станції + + + Відстань: + + + Тривалість: + + + + Перегляд типу + + + Перегляд типу + + + Карта + + + Інформація + + + Список + + + Так + + + Ні + + diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx index 197951d6c..4c591f2e1 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Anhänge diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx index 1238afbc5..0b74a151a 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Pièces jointes diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx index 20611ec2e..2792bc8da 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Allegati diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx index e6f0e8aab..8984cca6f 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Załączniki diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx index 441d317d1..9660cf9cf 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Bilagor diff --git a/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx b/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx index 657a74611..ce786b5b7 100644 --- a/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Messages/Messages.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Вкладення diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.de.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.de.resx index cd174a6bb..ac56404b1 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.es.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.es.resx index 9cfa436fb..6baf97524 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.es.resx @@ -162,4 +162,10 @@ Ver nota + + Solo administradores + + + Todos + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.fr.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.fr.resx index 74dc88145..7c1c89731 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.it.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.it.resx index 83ea9b619..d8e566320 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.pl.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.pl.resx index d6e95eef5..629b9575c 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.sv.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.sv.resx index cd174a6bb..ac56404b1 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Notes/Note.uk.resx b/Core/Resgrid.Localization/Areas/User/Notes/Note.uk.resx index 8f38733de..b6e9076a6 100644 --- a/Core/Resgrid.Localization/Areas/User/Notes/Note.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Notes/Note.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Admins Only diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx index 50f94ebf9..53744c0b9 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx @@ -36,7 +36,7 @@ الاسم الأول الاسم الأول المجموعة - الرئيسية + المنزل رقم الهوية رقم التعريف المخصص مدير المجموعة؟ @@ -58,10 +58,10 @@ إشعار المستخدم؟ أزل التحديد إذا كنت لا تريد من Resgrid إشعار المستخدم بإنشاء هذا الحساب. - Require Password Change + اشتراط تغيير كلمة المرور - User will be required to change their password on first login. + سيُطلب من المستخدم تغيير كلمة المرور عند أول تسجيل دخول. لا يوجد أفراد غير منتمين لمجموعة الأفراد diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx index 3322a6eff..7dc8e5fd8 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Kontoinformationen - Add a Person + Person hinzufügen - Add a single person + Eine einzelne Person hinzufügen - Existing User Added + Bestehender Benutzer hinzugefügt - Add Person + Person hinzufügen - Add Rank + Dienstgrad hinzufügen - Add Role + Rolle hinzufügen - Call Options + Einsatzoptionen - Change Password + Passwort ändern - Click the checkbox below to delete this user. + Aktivieren Sie das Kontrollkästchen unten, um diesen Benutzer zu löschen. - Confirm Delete? + Löschen bestätigen? - Confirm the users password (must match the one above) + Bestätigen Sie das Passwort des Benutzers (muss mit dem obigen übereinstimmen) Passwort bestätigen - Contact Details + Kontaktdaten - Are you sure you want to delete this user? + Möchten Sie diesen Benutzer wirklich löschen? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Wenn Sie diesen Benutzer löschen, werden die unten stehenden Informationen dauerhaft gelöscht. Es wird empfohlen, den Benutzer zu deaktivieren, wenn er die Abteilung oder Organisation verlässt. Löschen Sie den Benutzer erst, wenn genügend Zeit vergangen ist, oder wenn das Konto versehentlich angelegt oder nie verwendet wurde. - Delete Person + Person löschen - Edit Role + Rolle bearbeiten E-Mail - Email Addresses must be unique + E-Mail-Adressen müssen eindeutig sein E-Mail-Adresse - Email Address (must be unique) + E-Mail-Adresse (muss eindeutig sein) - This user + Dieser Benutzer - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , der anhand der E-Mail-Adresse erkannt wurde, hatte bereits ein Resgrid-Konto in einer anderen Abteilung. Das Benutzerkonto wurde der Abteilung jetzt <b>mit den bisherigen Profileinstellungen (Name, Telefonnummern, Einstellungen usw.)</b> hinzugefügt. Da der Benutzer mehreren Abteilungen angehört, muss er diese Abteilung über "Ihre Abteilungen" im Profilmenü (unter dem Profilbild oben links) aktivieren, um ihre Informationen zu sehen. Vorhandenen Benutzer hinzufügen @@ -159,19 +150,19 @@ Vorname - Group + Gruppe - Startseite + Privat - ID Number + ID-Nummer - Custom Identication Number + Eigene Kennnummer - Is Group Admin? + Gruppenadministrator? Nachname @@ -180,49 +171,49 @@ Nachname - Manage Invites + Einladungen verwalten - Invite multiple people + Mehrere Personen einladen - Manage Roles + Rollen verwalten - Message Options + Nachrichtenoptionen - Mobile + Mobil - Mobile Carrier + Mobilfunkanbieter - Mobile Number + Mobilnummer - Mobile Phone Number + Mobiltelefonnummer - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Passwörter müssen mindestens 8 Zeichen lang sein und eine Ziffer sowie einen Groß- und einen Kleinbuchstaben enthalten - New Password + Neues Passwort - No Personnel In Department + Kein Personal in der Abteilung - No Personnel in this Group + Kein Personal in dieser Gruppe - Notification Options + Benachrichtigungsoptionen - Notify User? + Benutzer benachrichtigen? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Deaktivieren Sie das Kontrollkästchen, wenn Resgrid den Benutzer nicht über die Erstellung dieses Kontos benachrichtigen soll. Passwortaenderung erforderlich @@ -231,34 +222,34 @@ Der Benutzer muss sein Passwort bei der ersten Anmeldung aendern. - No UnGrouped Personnel + Kein nicht gruppiertes Personal Personal - Phone Number(s) + Telefonnummer(n) Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + WARNUNG: Dadurch wird dieser Dienstgrad dauerhaft gelöscht. Dienstgrad wirklich löschen: - Personnel Ranks + Dienstgrade - Reactivate Person + Person reaktivieren - This user + Dieser Benutzer - based on email address, already had an account in the + hatte laut E-Mail-Adresse bereits ein Konto in der - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + Abteilung, wurde aber zuvor gelöscht. Das Benutzerkonto wurde jetzt wiederhergestellt, in dieser Abteilung reaktiviert und erscheint <b>mit den bisherigen Profileinstellungen (Name, Telefonnummern, Einstellungen usw.)</b> in der Abteilung. Abteilung, wurde aber entfernt. Durch die Reaktivierung kehrt das Konto mit seinen bisherigen Profileinstellungen (Name, Telefonnummern, Einstellungen) in diese Abteilung zurück. Die Person kehrt als normales Mitglied zurück; Administratorrechte müssen bei Bedarf separat erneut vergeben werden. @@ -267,43 +258,43 @@ Reaktivieren - WARNING: This will permanently delete this role. Are you sure you want to delete the role + WARNUNG: Dadurch wird diese Rolle dauerhaft gelöscht. Rolle wirklich löschen: - A description of the role + Eine Beschreibung der Rolle - Name of the Role + Name der Rolle - Personnel Roles + Personalrollen Rollen - Set Person Staffing + Bereitschaft der Person festlegen - Set Person Status + Status der Person festlegen - Set Staffing + Bereitschaft festlegen - Set Staffing for Selected Personnel + Bereitschaft für ausgewähltes Personal festlegen - Set Status + Status festlegen - Set Status for Selected Personnel + Status für ausgewähltes Personal festlegen - Special Note + Besonderer Hinweis - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + Die zugrunde liegenden Benutzerdaten bleiben im System erhalten, damit Protokolle, Berichte und Verlauf der Abteilung vollständig bleiben. Es wird empfohlen, vor dem Löschen des Benutzers alle personenbezogenen Daten (PII/PHI) im Benutzerkonto zu entfernen, z. B. Telefonnummern, Adressen usw. Besetzung @@ -312,58 +303,58 @@ Bundesland - Text + SMS - You may incur additional changes for SMS/Text depending on your mobile plan. + Je nach Mobilfunktarif können für SMS zusätzliche Kosten anfallen. - User + Benutzer - User Details + Benutzerdetails - The User Name must be unique + Der Benutzername muss eindeutig sein Benutzername - Users in Rank + Benutzer mit diesem Dienstgrad - Users In Role + Benutzer in der Rolle - View Person + Person anzeigen Bericht erstellen - View Person Events + Ereignisse der Person anzeigen - View Events + Ereignisse anzeigen - Events for + Ereignisse für - Are you sure you want to permanently delete all statuses for this person? + Möchten Sie wirklich alle Status dieser Person dauerhaft löschen? - Clear out all Statuses For Person + Alle Status der Person löschen - Delete All + Alle löschen - Yes I'm sure + Ja, ich bin sicher - View Role + Rolle anzeigen Unfertige Datensätze diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx index f3d38495a..431eacc78 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx @@ -211,7 +211,7 @@ Ομάδα - Αρχική + Οικία Αριθμός Ταυτότητας diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx index 769aa7557..da1b16686 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx @@ -184,7 +184,7 @@ Dirección de correo electrónico (debe ser única) - este usuario + Este usuario , según la dirección de correo electrónico, ya tenía una cuenta de Resgrid en otro departamento. La cuenta de usuario ahora se ha agregado <b>con su configuración de perfil anterior (nombre, números de teléfono, preferencias, etc.)</b> al departamento. Debido a que el usuario está en varios departamentos, necesita activar este departamento para ver su información desde su opción "Ver sus departamentos" en el menú desplegable de su perfil (debajo de la imagen de perfil en la esquina superior izquierda). @@ -211,7 +211,7 @@ Grupo - Hogar + Casa Número de identificación @@ -286,13 +286,13 @@ Personal - Números de teléfono) + Número(s) de teléfono - Empujar + Push - ADVERTENCIA: Esto eliminará permanentemente este rango. ¿Está seguro de que desea eliminar el rango? + ADVERTENCIA: Esto eliminará permanentemente este rango. ¿Está seguro de que desea eliminar el rango Rangos de personal @@ -301,7 +301,7 @@ Reactivar persona - este usuario + Este usuario basado en la dirección de correo electrónico, ya tenía una cuenta en el @@ -316,7 +316,7 @@ Reactivar - ADVERTENCIA: Esto eliminará permanentemente este rol. ¿Estás seguro de que quieres eliminar el rol? + ADVERTENCIA: Esto eliminará permanentemente este rol. ¿Está seguro de que desea eliminar el rol Una descripción del rol. @@ -352,7 +352,7 @@ Nota especial - Los datos de usuario subyacentes se conservan en el sistema para que los registros, los informes y el historial se conserven correctamente para el departamento. Se recomienda modificar toda la PII/PHI de la cuenta de usuario para eliminarla, es decir, números de teléfono, direcciones, etc., antes de eliminar al usuario. + Los datos de usuario subyacentes se conservan en el sistema para que las bitácoras, los informes y el historial se conserven correctamente para el departamento. Se recomienda modificar toda la PII/PHI de la cuenta de usuario para eliminarla, es decir, números de teléfono, direcciones, etc., antes de eliminar al usuario. dotación de personal @@ -400,7 +400,7 @@ Eventos para - Are you sure you want to permanently delete all statuses for this person? + ¿Está seguro de que desea eliminar permanentemente todos los estados de esta persona? Borrar todos los estados @@ -409,7 +409,7 @@ Eliminar todo - Sí, estoy seguro + Sí, confirmo Ver rol diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx index 26421423a..c53a003ca 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Informations du compte - Add a Person + Ajouter une personne - Add a single person + Ajouter une seule personne - Existing User Added + Utilisateur existant ajouté - Add Person + Ajouter une personne - Add Rank + Ajouter un grade - Add Role + Ajouter un rôle - Call Options + Options d'appel - Change Password + Changer le mot de passe - Click the checkbox below to delete this user. + Cochez la case ci-dessous pour supprimer cet utilisateur. - Confirm Delete? + Confirmer la suppression ? - Confirm the users password (must match the one above) + Confirmez le mot de passe de l'utilisateur (doit correspondre à celui ci-dessus) Confirmer le mot de passe - Contact Details + Coordonnées - Are you sure you want to delete this user? + Voulez-vous vraiment supprimer cet utilisateur ? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Si vous supprimez cet utilisateur, les informations ci-dessous seront définitivement supprimées. La procédure recommandée consiste à désactiver l'utilisateur lorsqu'il quitte le département ou l'organisation. Ne supprimez l'utilisateur que lorsque suffisamment de temps s'est écoulé, ou si le compte a été créé par erreur ou n'a jamais été utilisé. - Delete Person + Supprimer la personne - Edit Role + Modifier le rôle Courriel - Email Addresses must be unique + Les adresses e-mail doivent être uniques Adresse e-mail - Email Address (must be unique) + Adresse e-mail (doit être unique) - This user + Cet utilisateur - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , identifié par son adresse e-mail, avait déjà un compte Resgrid dans un autre département. Le compte a maintenant été ajouté au département <b>avec ses paramètres de profil précédents (nom, numéros de téléphone, préférences, etc.)</b>. Comme l'utilisateur appartient à plusieurs départements, il doit activer ce département depuis « Vos départements » dans le menu du profil (sous la photo de profil, en haut à gauche) pour en voir les informations. Ajouter un utilisateur existant @@ -159,19 +150,19 @@ Prénom - Group + Groupe - Accueil + Domicile - ID Number + Numéro d'identification - Custom Identication Number + Numéro d'identification personnalisé - Is Group Admin? + Administrateur du groupe ? Nom de famille @@ -180,49 +171,49 @@ Nom de famille - Manage Invites + Gérer les invitations - Invite multiple people + Inviter plusieurs personnes - Manage Roles + Gérer les rôles - Message Options + Options des messages Mobile - Mobile Carrier + Opérateur mobile - Mobile Number + Numéro de mobile - Mobile Phone Number + Numéro de téléphone mobile - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Les mots de passe doivent comporter au moins 8 caractères et contenir un chiffre, une lettre majuscule et une lettre minuscule - New Password + Nouveau mot de passe - No Personnel In Department + Aucun personnel dans le département - No Personnel in this Group + Aucun personnel dans ce groupe - Notification Options + Options de notification - Notify User? + Avertir l'utilisateur ? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Décochez cette case si vous ne voulez pas que Resgrid informe l'utilisateur de la création de ce compte. Exiger le changement de mot de passe @@ -231,34 +222,34 @@ L'utilisateur devra changer son mot de passe lors de la premiere connexion. - No UnGrouped Personnel + Aucun personnel non groupé Personnel - Phone Number(s) + Numéro(s) de téléphone Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + AVERTISSEMENT : cette action supprimera définitivement ce grade. Voulez-vous vraiment supprimer le grade - Personnel Ranks + Grades du personnel - Reactivate Person + Réactiver la personne - This user + Cet utilisateur - based on email address, already had an account in the + d'après son adresse e-mail, avait déjà un compte dans le - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + département, mais avait été supprimé. Le compte a maintenant été restauré et réactivé dans ce département et apparaîtra <b>avec ses paramètres de profil précédents (nom, numéros de téléphone, préférences, etc.)</b> dans le département. département, mais en a été retiré. La réactivation rétablit le compte dans ce département avec ses paramètres de profil précédents (nom, numéros de téléphone, préférences). La personne revient comme membre ordinaire ; accordez de nouveau les droits d'administrateur séparément si nécessaire. @@ -267,43 +258,43 @@ Réactiver - WARNING: This will permanently delete this role. Are you sure you want to delete the role + AVERTISSEMENT : cette action supprimera définitivement ce rôle. Voulez-vous vraiment supprimer le rôle - A description of the role + Une description du rôle - Name of the Role + Nom du rôle - Personnel Roles + Rôles du personnel Rôles - Set Person Staffing + Définir la disponibilité de la personne - Set Person Status + Définir le statut de la personne - Set Staffing + Définir la disponibilité - Set Staffing for Selected Personnel + Définir la disponibilité du personnel sélectionné - Set Status + Définir le statut - Set Status for Selected Personnel + Définir le statut du personnel sélectionné - Special Note + Remarque importante - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + Les données utilisateur sous-jacentes sont conservées dans le système afin que les journaux, les rapports et l'historique du département restent complets. Avant de supprimer l'utilisateur, il est recommandé de modifier le compte pour en retirer toutes les données personnelles et de santé (PII/PHI), p. ex. numéros de téléphone, adresses, etc. Dotation en personnel @@ -315,28 +306,28 @@ Texte - You may incur additional changes for SMS/Text depending on your mobile plan. + Des frais supplémentaires peuvent s'appliquer aux SMS selon votre forfait mobile. - User + Utilisateur - User Details + Détails de l'utilisateur - The User Name must be unique + Le nom d'utilisateur doit être unique Nom d'utilisateur - Users in Rank + Utilisateurs ayant ce grade - Users In Role + Utilisateurs dans le rôle - View Person + Voir la personne Générer un rapport @@ -351,7 +342,7 @@ Événements pour - Are you sure you want to permanently delete all statuses for this person? + Voulez-vous vraiment supprimer définitivement tous les statuts de cette personne ? Effacer tous les statuts @@ -360,10 +351,10 @@ Tout supprimer - Oui, je suis sûr + Oui, je confirme - View Role + Voir le rôle Enregistrements non finalisés diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx index d7b0d8935..642bf3017 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Informazioni account - Add a Person + Aggiungi una persona - Add a single person + Aggiungi una singola persona - Existing User Added + Utente esistente aggiunto - Add Person + Aggiungi persona - Add Rank + Aggiungi grado - Add Role + Aggiungi ruolo - Call Options + Opzioni chiamata - Change Password + Cambia password - Click the checkbox below to delete this user. + Seleziona la casella qui sotto per eliminare questo utente. - Confirm Delete? + Confermare l'eliminazione? - Confirm the users password (must match the one above) + Conferma la password dell'utente (deve corrispondere a quella sopra) Conferma password - Contact Details + Recapiti - Are you sure you want to delete this user? + Vuoi davvero eliminare questo utente? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Se elimini questo utente, le informazioni riportate di seguito verranno eliminate definitivamente. La procedura consigliata è disattivare l'utente quando lascia il dipartimento o l'organizzazione. Elimina l'utente solo dopo che è trascorso abbastanza tempo, oppure se l'account è stato creato per errore o non è mai stato usato. - Delete Person + Elimina persona - Edit Role + Modifica ruolo - Email + E-mail - Email Addresses must be unique + Gli indirizzi e-mail devono essere univoci Indirizzo e-mail - Email Address (must be unique) + Indirizzo e-mail (deve essere univoco) - This user + Questo utente - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , in base all'indirizzo e-mail, aveva già un account Resgrid in un altro dipartimento. L'account è stato ora aggiunto al dipartimento <b>con le impostazioni del profilo precedenti (nome, numeri di telefono, preferenze, ecc.)</b>. Poiché l'utente appartiene a più dipartimenti, deve attivare questo dipartimento da "I tuoi dipartimenti" nel menu del profilo (sotto l'immagine del profilo, in alto a sinistra) per vederne le informazioni. Aggiungi utente esistente @@ -159,19 +150,19 @@ Nome - Group + Gruppo - Home + Casa - ID Number + Numero identificativo - Custom Identication Number + Numero identificativo personalizzato - Is Group Admin? + Amministratore del gruppo? Cognome @@ -180,49 +171,49 @@ Cognome - Manage Invites + Gestisci inviti - Invite multiple people + Invita più persone - Manage Roles + Gestisci ruoli - Message Options + Opzioni messaggi - Mobile + Cellulare - Mobile Carrier + Operatore mobile - Mobile Number + Numero di cellulare - Mobile Phone Number + Numero di cellulare - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Le password devono essere lunghe almeno 8 caratteri e contenere una cifra, una lettera maiuscola e una minuscola - New Password + Nuova password - No Personnel In Department + Nessun personale nel dipartimento - No Personnel in this Group + Nessun personale in questo gruppo - Notification Options + Opzioni di notifica - Notify User? + Avvisare l'utente? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Deseleziona la casella se non vuoi che Resgrid informi l'utente della creazione di questo account. Richiedi cambio password @@ -231,34 +222,34 @@ L'utente dovra cambiare la password al primo accesso. - No UnGrouped Personnel + Nessun personale non raggruppato Personale - Phone Number(s) + Numeri di telefono Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + ATTENZIONE: questo grado verrà eliminato definitivamente. Vuoi davvero eliminare il grado - Personnel Ranks + Gradi del personale - Reactivate Person + Riattiva persona - This user + Questo utente - based on email address, already had an account in the + in base all'indirizzo e-mail, aveva già un account nel - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + dipartimento, ma era stato eliminato in precedenza. L'account è stato ora ripristinato e riattivato in questo dipartimento e comparirà <b>con le impostazioni del profilo precedenti (nome, numeri di telefono, preferenze, ecc.)</b> nel dipartimento. dipartimento, ma è stato rimosso. La riattivazione riporta l'account in questo dipartimento con le precedenti impostazioni del profilo (nome, numeri di telefono, preferenze). La persona rientra come membro normale; concedere di nuovo i diritti di amministratore separatamente se necessario. @@ -267,43 +258,43 @@ Riattiva - WARNING: This will permanently delete this role. Are you sure you want to delete the role + ATTENZIONE: questo ruolo verrà eliminato definitivamente. Vuoi davvero eliminare il ruolo - A description of the role + Una descrizione del ruolo - Name of the Role + Nome del ruolo - Personnel Roles + Ruoli del personale Ruoli - Set Person Staffing + Imposta la disponibilità della persona - Set Person Status + Imposta lo stato della persona - Set Staffing + Imposta disponibilità - Set Staffing for Selected Personnel + Imposta la disponibilità del personale selezionato - Set Status + Imposta stato - Set Status for Selected Personnel + Imposta lo stato del personale selezionato - Special Note + Nota importante - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + I dati dell'utente vengono conservati nel sistema affinché log, rapporti e cronologia del dipartimento restino completi. Prima di eliminare l'utente, si consiglia di modificare l'account per rimuovere tutti i dati personali e sanitari (PII/PHI), ad es. numeri di telefono, indirizzi, ecc. Personale @@ -315,28 +306,28 @@ Testo - You may incur additional changes for SMS/Text depending on your mobile plan. + A seconda del tuo piano tariffario, gli SMS potrebbero comportare costi aggiuntivi. - User + Utente - User Details + Dettagli utente - The User Name must be unique + Il nome utente deve essere univoco Nome utente - Users in Rank + Utenti con questo grado - Users In Role + Utenti nel ruolo - View Person + Visualizza persona Genera report @@ -351,7 +342,7 @@ Eventi per - Are you sure you want to permanently delete all statuses for this person? + Vuoi davvero eliminare definitivamente tutti gli stati di questa persona? Cancella tutti gli stati @@ -360,10 +351,10 @@ Elimina tutto - Sì, sono sicuro + Sì, confermo - View Role + Visualizza ruolo Record non finalizzati diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx index 1a24a9d42..5db05554e 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Informacje o koncie - Add a Person + Dodaj osobę - Add a single person + Dodaj pojedynczą osobę - Existing User Added + Dodano istniejącego użytkownika - Add Person + Dodaj osobę - Add Rank + Dodaj stopień - Add Role + Dodaj rolę - Call Options + Opcje zgłoszeń - Change Password + Zmień hasło - Click the checkbox below to delete this user. + Zaznacz pole wyboru poniżej, aby usunąć tego użytkownika. - Confirm Delete? + Potwierdzić usunięcie? - Confirm the users password (must match the one above) + Potwierdź hasło użytkownika (musi być zgodne z powyższym) Potwierdź hasło - Contact Details + Dane kontaktowe - Are you sure you want to delete this user? + Czy na pewno chcesz usunąć tego użytkownika? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Jeśli usuniesz tego użytkownika, poniższe informacje zostaną trwale usunięte. Zalecaną procedurą jest wyłączenie użytkownika, gdy odchodzi z oddziału lub organizacji. Usuń użytkownika dopiero po upływie odpowiedniego czasu albo jeśli konto utworzono przez pomyłkę lub nigdy go nie używano. - Delete Person + Usuń osobę - Edit Role + Edytuj rolę - Email + E-mail - Email Addresses must be unique + Adresy e-mail muszą być unikalne Adres e-mail - Email Address (must be unique) + Adres e-mail (musi być unikalny) - This user + Ten użytkownik - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , na podstawie adresu e-mail, miał już konto Resgrid w innym oddziale. Konto użytkownika zostało teraz dodane do oddziału <b>z poprzednimi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje itp.)</b>. Ponieważ użytkownik należy do kilku oddziałów, musi aktywować ten oddział w "Twoje oddziały" w menu profilu (pod zdjęciem profilowym w lewym górnym rogu), aby zobaczyć jego informacje. Dodaj istniejącego użytkownika @@ -159,19 +150,19 @@ Imię - Group + Grupa - Strona główna + Domowy - ID Number + Numer identyfikacyjny - Custom Identication Number + Własny numer identyfikacyjny - Is Group Admin? + Administrator grupy? Nazwisko @@ -180,49 +171,49 @@ Nazwisko - Manage Invites + Zarządzaj zaproszeniami - Invite multiple people + Zaproś wiele osób - Manage Roles + Zarządzaj rolami - Message Options + Opcje wiadomości - Mobile + Komórkowy - Mobile Carrier + Operator komórkowy - Mobile Number + Numer komórkowy - Mobile Phone Number + Numer telefonu komórkowego - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Hasło musi mieć co najmniej 8 znaków i zawierać cyfrę oraz wielką i małą literę - New Password + Nowe hasło - No Personnel In Department + Brak personelu w oddziale - No Personnel in this Group + Brak personelu w tej grupie - Notification Options + Opcje powiadomień - Notify User? + Powiadomić użytkownika? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Odznacz, jeśli nie chcesz, aby Resgrid powiadamiał użytkownika o utworzeniu tego konta. Wymagaj zmiany hasla @@ -231,34 +222,34 @@ Uzytkownik bedzie musial zmienic haslo przy pierwszym logowaniu. - No UnGrouped Personnel + Brak niezgrupowanego personelu Personel - Phone Number(s) + Numery telefonów Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + OSTRZEŻENIE: Ta operacja trwale usunie ten stopień. Czy na pewno chcesz usunąć stopień - Personnel Ranks + Stopnie personelu - Reactivate Person + Reaktywuj osobę - This user + Ten użytkownik - based on email address, already had an account in the + na podstawie adresu e-mail, miał już konto w - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + oddziale, ale został wcześniej usunięty. Konto użytkownika zostało teraz przywrócone i ponownie aktywowane w tym oddziale i pojawi się w nim <b>z poprzednimi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje itp.)</b>. oddziale, ale został usunięty. Ponowna aktywacja przywraca konto w tym oddziale z poprzednimi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje). Osoba wraca jako zwykły członek; w razie potrzeby uprawnienia administratora należy nadać ponownie osobno. @@ -267,43 +258,43 @@ Aktywuj ponownie - WARNING: This will permanently delete this role. Are you sure you want to delete the role + OSTRZEŻENIE: Ta operacja trwale usunie tę rolę. Czy na pewno chcesz usunąć rolę - A description of the role + Opis roli - Name of the Role + Nazwa roli - Personnel Roles + Role personelu Role - Set Person Staffing + Ustaw gotowość osoby - Set Person Status + Ustaw status osoby - Set Staffing + Ustaw gotowość - Set Staffing for Selected Personnel + Ustaw gotowość wybranego personelu - Set Status + Ustaw status - Set Status for Selected Personnel + Ustaw status wybranego personelu - Special Note + Ważna uwaga - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + Dane użytkownika pozostają w systemie, aby dzienniki, raporty i historia oddziału zostały prawidłowo zachowane. Przed usunięciem użytkownika zaleca się usunięcie z konta wszystkich danych osobowych i medycznych (PII/PHI), np. numerów telefonów, adresów itp. Obsada @@ -315,28 +306,28 @@ Tekst - You may incur additional changes for SMS/Text depending on your mobile plan. + W zależności od Twojego planu taryfowego za SMS-y mogą zostać naliczone dodatkowe opłaty. - User + Użytkownik - User Details + Szczegóły użytkownika - The User Name must be unique + Nazwa użytkownika musi być unikalna Nazwa użytkownika - Users in Rank + Użytkownicy z tym stopniem - Users In Role + Użytkownicy w roli - View Person + Wyświetl osobę Wygeneruj raport @@ -351,7 +342,7 @@ Zdarzenia dla - Are you sure you want to permanently delete all statuses for this person? + Czy na pewno chcesz trwale usunąć wszystkie statusy tej osoby? Wyczyść wszystkie statusy @@ -360,10 +351,10 @@ Usuń wszystko - Tak, jestem pewien + Tak, potwierdzam - View Role + Wyświetl rolę Niezakończone rekordy diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx index f9cc29eff..c7435662f 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Kontoinformation - Add a Person + Lägg till en person - Add a single person + Lägg till en enskild person - Existing User Added + Befintlig användare tillagd - Add Person + Lägg till person - Add Rank + Lägg till grad - Add Role + Lägg till roll - Call Options + Larmalternativ - Change Password + Byt lösenord - Click the checkbox below to delete this user. + Markera kryssrutan nedan för att ta bort användaren. - Confirm Delete? + Bekräfta borttagning? - Confirm the users password (must match the one above) + Bekräfta användarens lösenord (måste matcha lösenordet ovan) Bekräfta lösenord - Contact Details + Kontaktuppgifter - Are you sure you want to delete this user? + Vill du verkligen ta bort användaren? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Om du tar bort användaren raderas informationen nedan permanent. Det rekommenderade förfarandet är att inaktivera användaren om hen lämnar avdelningen eller organisationen. Ta bara bort användaren när tillräckligt lång tid har gått, eller om kontot skapades av misstag eller aldrig har använts. - Delete Person + Ta bort person - Edit Role + Redigera roll E-post - Email Addresses must be unique + E-postadresser måste vara unika E-postadress - Email Address (must be unique) + E-postadress (måste vara unik) - This user + Den här användaren - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , som identifierats via e-postadressen, hade redan ett Resgrid-konto i en annan avdelning. Användarkontot har nu lagts till i avdelningen <b>med sina tidigare profilinställningar (namn, telefonnummer, inställningar osv.)</b>. Eftersom användaren finns i flera avdelningar måste hen aktivera den här avdelningen via "Dina avdelningar" i profilmenyn (under profilbilden uppe till vänster) för att se dess information. Lägg till befintlig användare @@ -159,19 +150,19 @@ Förnamn - Group + Grupp Hem - ID Number + ID-nummer - Custom Identication Number + Eget ID-nummer - Is Group Admin? + Gruppadministratör? Efternamn @@ -180,49 +171,49 @@ Efternamn - Manage Invites + Hantera inbjudningar - Invite multiple people + Bjud in flera personer - Manage Roles + Hantera roller - Message Options + Meddelandealternativ - Mobile + Mobil - Mobile Carrier + Mobiloperatör - Mobile Number + Mobilnummer - Mobile Phone Number + Mobiltelefonnummer - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Lösenord måste vara minst 8 tecken långa och innehålla en siffra samt en stor och en liten bokstav - New Password + Nytt lösenord - No Personnel In Department + Ingen personal i avdelningen - No Personnel in this Group + Ingen personal i den här gruppen - Notification Options + Aviseringsalternativ - Notify User? + Meddela användaren? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Avmarkera om du inte vill att Resgrid ska meddela användaren att kontot har skapats. Krav pa losenordsbyte @@ -231,34 +222,34 @@ Anvandaren maste byta losenord vid forsta inloggningen. - No UnGrouped Personnel + Ingen ogrupperad personal Personal - Phone Number(s) + Telefonnummer Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + VARNING: Detta tar bort graden permanent. Vill du verkligen ta bort graden - Personnel Ranks + Personalgrader - Reactivate Person + Återaktivera person - This user + Den här användaren - based on email address, already had an account in the + hade enligt e-postadressen redan ett konto i - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + avdelningen men hade tagits bort tidigare. Användarkontot har nu återställts och återaktiverats i den här avdelningen och visas <b>med sina tidigare profilinställningar (namn, telefonnummer, inställningar osv.)</b> i avdelningen. avdelningen men har tagits bort. Återaktivering för tillbaka kontot i den här avdelningen med dess tidigare profilinställningar (namn, telefonnummer, inställningar). Personen återvänder som vanlig medlem; ge administratörsbehörighet på nytt separat om den behövs. @@ -267,43 +258,43 @@ Återaktivera - WARNING: This will permanently delete this role. Are you sure you want to delete the role + VARNING: Detta tar bort rollen permanent. Vill du verkligen ta bort rollen - A description of the role + En beskrivning av rollen - Name of the Role + Rollens namn - Personnel Roles + Personalroller Roller - Set Person Staffing + Ange personens beredskap - Set Person Status + Ange personens status - Set Staffing + Ange beredskap - Set Staffing for Selected Personnel + Ange beredskap för vald personal - Set Status + Ange status - Set Status for Selected Personnel + Ange status för vald personal - Special Note + Särskild anmärkning - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + Underliggande användardata behålls i systemet så att loggar, rapporter och historik bevaras för avdelningen. Innan du tar bort användaren rekommenderas att du tar bort alla personuppgifter (PII/PHI) från användarkontot, t.ex. telefonnummer, adresser osv. Bemanning @@ -312,31 +303,31 @@ Delstat - Text + SMS - You may incur additional changes for SMS/Text depending on your mobile plan. + Beroende på ditt mobilabonnemang kan sms medföra extra kostnader. - User + Användare - User Details + Användarinformation - The User Name must be unique + Användarnamnet måste vara unikt Användarnamn - Users in Rank + Användare med graden - Users In Role + Användare i rollen - View Person + Visa person Generera rapport @@ -351,7 +342,7 @@ Händelser för - Are you sure you want to permanently delete all statuses for this person? + Vill du verkligen ta bort alla statusar för den här personen permanent? Rensa alla statusar @@ -363,7 +354,7 @@ Ja, jag är säker - View Role + Visa roll Ofärdiga poster diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx index 108c8558d..979482f92 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx @@ -59,86 +59,77 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Account Information + Інформація про обліковий запис - Add a Person + Додати особу - Add a single person + Додати одну особу - Existing User Added + Наявного користувача додано - Add Person + Додати особу - Add Rank + Додати звання - Add Role + Додати роль - Call Options + Параметри викликів - Change Password + Змінити пароль - Click the checkbox below to delete this user. + Позначте прапорець нижче, щоб видалити цього користувача. - Confirm Delete? + Підтвердити видалення? - Confirm the users password (must match the one above) + Підтвердьте пароль користувача (має збігатися з указаним вище) Підтвердити пароль - Contact Details + Контактні дані - Are you sure you want to delete this user? + Ви дійсно бажаєте видалити цього користувача? - If you choose to delete this user the information below will be permanently deleted. The recommended procedure is to disable the user if they leave the department or organization. Only delete the user after enough time has gone by, the account was a mistake or never used. + Якщо ви видалите цього користувача, наведену нижче інформацію буде видалено остаточно. Рекомендовано вимкнути користувача, якщо він залишає підрозділ або організацію. Видаляйте користувача лише після того, як мине достатньо часу, або якщо обліковий запис створено помилково чи він ніколи не використовувався. - Delete Person + Видалити особу - Edit Role + Редагувати роль Електронна пошта - Email Addresses must be unique + Адреси електронної пошти мають бути унікальними Адреса електронної пошти - Email Address (must be unique) + Адреса електронної пошти (має бути унікальною) - This user + Цей користувач - , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + , судячи з адреси електронної пошти, уже мав обліковий запис Resgrid в іншому підрозділі. Тепер обліковий запис додано до підрозділу <b>з попередніми налаштуваннями профілю (ім'я, номери телефонів, налаштування тощо)</b>. Оскільки користувач належить до кількох підрозділів, йому потрібно активувати цей підрозділ через "Ваші підрозділи" в меню профілю (під фото профілю у верхньому лівому куті), щоб бачити його інформацію. Додати наявного користувача @@ -159,19 +150,19 @@ Ім'я - Group + Група - Головна + Домашній - ID Number + Ідентифікаційний номер - Custom Identication Number + Власний ідентифікаційний номер - Is Group Admin? + Адміністратор групи? Прізвище @@ -180,49 +171,49 @@ Прізвище - Manage Invites + Керування запрошеннями - Invite multiple people + Запросити кількох осіб - Manage Roles + Керування ролями - Message Options + Параметри повідомлень - Mobile + Мобільний - Mobile Carrier + Мобільний оператор - Mobile Number + Номер мобільного - Mobile Phone Number + Номер мобільного телефону - Passwords must be 8 characters or longer and include a digit (number), an uppercase and lowercase letter + Пароль має містити щонайменше 8 символів, зокрема цифру, велику та малу літери - New Password + Новий пароль - No Personnel In Department + У підрозділі немає персоналу - No Personnel in this Group + У цій групі немає персоналу - Notification Options + Параметри сповіщень - Notify User? + Сповістити користувача? - Uncheck if you don't want Resgrid to notify the user of this account creation. + Зніміть позначку, якщо не хочете, щоб Resgrid сповіщав користувача про створення цього облікового запису. Vymahaty zminu parolya @@ -231,34 +222,34 @@ Korystuvach povynen zminyty parol pid chas pershoho vkhodu. - No UnGrouped Personnel + Немає незгрупованого персоналу Персонал - Phone Number(s) + Номери телефонів Push - WARNING: This will permanently delete this rank. Are you sure you want to delete the rank + ПОПЕРЕДЖЕННЯ: це звання буде видалено остаточно. Ви дійсно бажаєте видалити звання - Personnel Ranks + Звання персоналу - Reactivate Person + Повторно активувати особу - This user + Цей користувач - based on email address, already had an account in the + судячи з адреси електронної пошти, уже мав обліковий запис у - department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + підрозділі, але раніше був видалений. Тепер обліковий запис користувача відновлено й повторно активовано в цьому підрозділі, і він відображатиметься в підрозділі <b>з попередніми налаштуваннями профілю (ім'я, номери телефонів, налаштування тощо)</b>. підрозділі, але був видалений. Повторна активація повертає обліковий запис у цей підрозділ із попередніми налаштуваннями профілю (ім'я, номери телефонів, уподобання). Особа повертається як звичайний член; за потреби надайте права адміністратора окремо. @@ -267,43 +258,43 @@ Активувати повторно - WARNING: This will permanently delete this role. Are you sure you want to delete the role + ПОПЕРЕДЖЕННЯ: цю роль буде видалено остаточно. Ви дійсно бажаєте видалити роль - A description of the role + Опис ролі - Name of the Role + Назва ролі - Personnel Roles + Ролі персоналу Ролі - Set Person Staffing + Встановити готовність особи - Set Person Status + Встановити статус особи - Set Staffing + Встановити готовність - Set Staffing for Selected Personnel + Встановити готовність вибраного персоналу - Set Status + Встановити статус - Set Status for Selected Personnel + Встановити статус вибраного персоналу - Special Note + Особлива примітка - Underlying user data is retained in the system so that logs, reports and history is properly retained for the department. It is recommended that all PII/PHI in the user account is altered to be removed, i.e. phone numbers, addresses, etc before you delete the user. + Базові дані користувача зберігаються в системі, щоб журнали, звіти та історія підрозділу залишалися повними. Перед видаленням користувача рекомендовано вилучити з облікового запису всі персональні та медичні дані (PII/PHI), наприклад номери телефонів, адреси тощо. Укомплектованість @@ -315,28 +306,28 @@ Текст - You may incur additional changes for SMS/Text depending on your mobile plan. + Залежно від вашого тарифного плану за SMS може стягуватися додаткова плата. - User + Користувач - User Details + Відомості про користувача - The User Name must be unique + Ім'я користувача має бути унікальним Ім'я користувача - Users in Rank + Користувачі з цим званням - Users In Role + Користувачі в ролі - View Person + Переглянути особу Згенерувати звіт @@ -351,7 +342,7 @@ Події для - Are you sure you want to permanently delete all statuses for this person? + Ви дійсно бажаєте остаточно видалити всі статуси цієї особи? Очистити всі статуси @@ -360,10 +351,10 @@ Видалити все - Так, я впевнений + Так, підтверджую - View Role + Переглянути роль Незавершені записи diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx index feb54542b..e2e674847 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx index 8e3b3ce50..8e3727834 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx index e2c6c37a3..32950764e 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx index 23be55714..f91868304 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx index 00779d6f7..1a0afb85a 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx index 9b4d0e94e..9ea50b011 100644 --- a/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Profile/Profile.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Add Certification diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index 22263687a..2e15a7b7b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -184,7 +184,7 @@ Registros ocultos para miembros sin grupo Registros sin ancla de grupo (siguen siendo de todo el departamento) Miembros sin grupo - Filas de registros antiguos sin grupo (siguen siendo de todo el departamento) + Filas de bitácoras antiguas sin grupo (siguen siendo de todo el departamento) Grupo Miembros Registros @@ -380,7 +380,7 @@ Tipo de carrocería - Aviso + Llamada Capturar @@ -434,7 +434,7 @@ Cobertura de la correspondencia - Cada tipo de aviso local sin código NERIS habrá que clasificarlo a mano durante el servicio. + Cada tipo de llamada local sin código NERIS habrá que clasificarlo a mano durante el servicio. Moneda @@ -527,7 +527,7 @@ Material y sustancias controladas utilizados - Disponibilidad en el momento del aviso + Disponibilidad en el momento de la llamada Se necesita un motivo para incorporar evidencia a un registro oficial. @@ -584,7 +584,7 @@ Informes de incidente aún sin finalizar - Incluir entradas del registro heredado + Incluir entradas de bitácoras heredadas Incompleto @@ -2048,7 +2048,7 @@ Las selecciones no capturadas de esta página se borrarán. Elija como máximo 20 unidades y una ventana de 24 horas. Se muestrean hasta 24 posiciones por unidad; capture otras ventanas por separado. Elija el personal cuyo estado de cualificación corresponde a este informe. Los números de certificado y los archivos quedan en Certificaciones. - Captura las decisiones de despacho que Run Cards registró para el aviso de este informe. Una decisión no registrada no puede reconstruirse después. + Captura las decisiones de despacho que Run Cards registró para la llamada de este informe. Una decisión no registrada no puede reconstruirse después. Actualiza la evidencia de cada consumo de inventario registrado. No vuelve a consumir existencias. Seleccione los mensajes a conservar; se incluyen las respuestas del hilo. Cada página es una captura independiente, así que capture esta antes de avanzar. Los cuerpos se muestran como texto. Historial de envío y recuperación @@ -2080,17 +2080,17 @@ Fallido Sustituido por una revisión más reciente Esperando revisión del destino - Crear un aviso para esta actuación - Registre un incidente pasado y vincúlelo a esta actuación. El aviso se crea cerrado y no despacha a nadie. - Nombre del aviso + Crear una llamada para esta actuación + Registre un incidente pasado y vincúlelo a esta actuación. La llamada se crea cerrada y no despacha a nadie. + Nombre de la llamada Ocurrió el (hora local del departamento) Dirección - Naturaleza del aviso - Crear y vincular el aviso + Naturaleza de la llamada + Crear y vincular la llamada Revisión {0} comparada con la revisión {1} Imprimir la comparación en PDF Campos personalizados del departamento - Guarde el borrador para crear y vincular un aviso histórico. + Guarde el borrador para crear y vincular una llamada histórica. Incluir Elija un participante Participante diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index b7e8cfe79..01d18f53a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -144,19 +144,19 @@ Stampato il Impaginazione Le esportazioni includono al massimo {0} righe; restringete il filtro per esportarne di più. - Cerca rapporti + Cerca registri La ricerca non è disponibile al momento; l'elenco sottostante è filtrato senza il testo di ricerca. Vengono mostrate solo le corrispondenze più vicine; restringete la ricerca per vederne di più. Il testo della narrativa è incluso nella ricerca. - Il testo della narrativa non è indicizzato per questo dipartimento; la ricerca copre numeri di rapporto, riepiloghi e altri campi sicuri. + Il testo della narrativa non è indicizzato per questo dipartimento; la ricerca copre numeri di registro, riepiloghi e altri campi sicuri. Indice di ricerca Disattivato In linea Non in linea - Rapporti indicizzati + Registri indicizzati Il worker ricostruisce automaticamente l'indice di ricerca ogni volta che cambiano la politica di protezione o la versione del catalogo del dipartimento. La ricerca nella narrativa viene ritirata con l'adesione alla Protezione avanzata dei dati. Impaginazione di stampa - Come viene resa l'intestazione sulle stampe dei rapporti. Identità e logo provengono dal profilo del dipartimento; il contenuto del rapporto si stampa sempre dalla revisione bloccata. + Come viene resa l'intestazione sulle stampe dei registri. Identità e logo provengono dal profilo del dipartimento; il contenuto del registro si stampa sempre dalla revisione bloccata. Mostra logo Usa il nome breve Mostra indirizzo @@ -584,7 +584,7 @@ Rapporti d'intervento non ancora finalizzati - Includi le voci del registro storico + Includi le voci dei log storici Incompleto diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx index 55f49dc06..79dafa7b9 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.ar.resx @@ -210,7 +210,6 @@ عمل المعرف المحطة - الوحدات الحالة اسم المكالمة نوع المكالمة @@ -306,9 +305,6 @@ لم يتم تسجيل الانصراف - - الوقت الإجمالي - أوقات الوحدات لكل بلاغ diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx index 6e2494c0e..1981eb479 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.de.resx @@ -210,7 +210,6 @@ Arbeit Id Station - Einheiten Zustand Einsatzname Einsatztyp @@ -306,9 +305,6 @@ Nicht ausgecheckt - - Gesamtzeit - Einheitenzeiten je Einsatz diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx index b0fff4454..cfc23c50f 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.el.resx @@ -567,9 +567,6 @@ Σταθμός - - Μονάδες - Κατάσταση @@ -737,9 +734,6 @@ Δεν έχει δηλωθεί αναχώρηση - - Συνολικός Χρόνος - Χρόνοι μονάδων ανά κλήση diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx index be0e04117..28c340559 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.en.resx @@ -567,9 +567,6 @@ Station - - Units - Condition @@ -737,9 +734,6 @@ Not checked out - - Total Time - Call Unit Times diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx index 9e91a452b..cd375b48e 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.es.resx @@ -73,9 +73,9 @@ Informe de Preparación de Turno Este informe mostrará la preparación del turno para los próximos días de turno para cada turno. Informe de Actividad Anual del Departamento - Este informe mostrará la actividad de llamadas, registros, capacitación y personal del departamento en cifras anuales hasta la fecha. - Informe de Horas de Registro del Personal - Este informe mostrará el número de horas que el personal del departamento trabajó, entrenó y respondió a llamadas según los registros. + Este informe mostrará la actividad de llamadas, bitácoras, capacitación y personal del departamento en cifras anuales hasta la fecha. + Informe de Horas de Bitácora del Personal + Este informe mostrará el número de horas que el personal del departamento trabajó, se capacitó y respondió a llamadas según las entradas de bitácora (informes posteriores a la intervención). Informe de Resumen de Llamadas Este informe mostrará detalles sobre todas las llamadas en un rango de fechas seleccionado Informe de Historial de Dotación del Personal @@ -154,7 +154,7 @@ Horas de Llamada Horas de Trabajo Horas de Capacitación - Registros de Trabajo + Bitácoras de Trabajo Informe de Personal Resgrid Usuario Correo Electrónico @@ -196,7 +196,7 @@ Despachado En Marca de 14 Días Marca de 21 Días - Registro Resgrid + Bitácora Resgrid Distrito Departamento Tipo @@ -210,7 +210,6 @@ Trabajo Id Estación - Unidades Condición Nombre de Llamada Tipo de Llamada @@ -229,7 +228,7 @@ Asistencia Tiempo Total Tipo de Reunión - Tipo de Registro + Tipo de Bitácora Reunión Ubicación Presidiendo @@ -306,9 +305,6 @@ Sin registro de salida - - Tiempo Total - Tiempos de unidades por llamada diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx index 517fe75af..0d73b70c1 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.fr.resx @@ -210,7 +210,6 @@ Travail Id Station - Unités Condition Nom de l'Appel Type d'Appel @@ -306,9 +305,6 @@ Non pointé au départ - - Temps total - Horaires des unités par appel diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx index 259c05470..f80b5fab2 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.it.resx @@ -73,9 +73,9 @@ Rapporto Prontezza Turno Questo rapporto mostrerà la prontezza del turno per i prossimi giorni di turno per ogni turno. Rapporto Attività Annuale Dipartimento - Questo rapporto mostrerà l'attività di chiamate, registri, formazione e personale del dipartimento nei numeri da inizio anno. - Rapporto Ore Registro Personale - Questo rapporto mostrerà il numero di ore che il personale del dipartimento ha lavorato, si è formato e ha risposto alle chiamate secondo i registri. + Questo rapporto mostrerà l'attività di chiamate, log, formazione e personale del dipartimento nei numeri da inizio anno. + Rapporto Ore Log Personale + Questo rapporto mostrerà il numero di ore che il personale del dipartimento ha lavorato, si è formato e ha risposto alle chiamate secondo le voci dei log. Rapporto Riepilogo Chiamate Questo rapporto mostrerà i dettagli di tutte le chiamate in un intervallo di date selezionato Rapporto Storico Organico Personale @@ -154,7 +154,7 @@ Ore Chiamate Ore Lavoro Ore Formazione - Registri Lavoro + Log di lavoro Rapporto Personale Resgrid Nome Utente Email @@ -196,7 +196,7 @@ Inviato Il Segno 14 Giorni Segno 21 Giorni - Registro Resgrid + Log Resgrid Distretto Dipartimento Tipo @@ -210,7 +210,6 @@ Lavoro Id Stazione - Unità Condizione Nome Chiamata Tipo Chiamata @@ -229,7 +228,7 @@ Presenza Tempo Totale Tipo Riunione - Tipo Registro + Tipo Log Riunione Posizione Presieduto @@ -306,9 +305,6 @@ Uscita non registrata - - Tempo Totale - Tempi delle unità per chiamata diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx index f832d7fe9..fdca64175 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.pl.resx @@ -210,7 +210,6 @@ Praca Id Stacja - Jednostki Stan Nazwa Zgłoszenia Typ Zgłoszenia @@ -306,9 +305,6 @@ Nie wymeldowany - - Łączny czas - Czasy jednostek w zgłoszeniach diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx index 13fa926f0..134dd1c6b 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.sv.resx @@ -210,7 +210,6 @@ Arbete Id Station - Enheter Tillstånd Larmnamn Larmtyp @@ -306,9 +305,6 @@ Inte utcheckad - - Total tid - Enhetstider per larm diff --git a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx index 0c36d1ca0..8fcda3eef 100644 --- a/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Reports/Reports.uk.resx @@ -210,7 +210,6 @@ Робота Id Станція - Підрозділи Стан Назва Виклику Тип Виклику @@ -306,9 +305,6 @@ Вихід не зареєстровано - - Загальний час - Час підрозділів за викликами diff --git a/Core/Resgrid.Localization/Areas/User/Routes/Routes.ar.resx b/Core/Resgrid.Localization/Areas/User/Routes/Routes.ar.resx index e9e46ed49..e87e4be6b 100644 --- a/Core/Resgrid.Localization/Areas/User/Routes/Routes.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Routes/Routes.ar.resx @@ -184,5 +184,5 @@ ابحث عن الموقع عبر - عرض التفاصيل + المسارات المؤرشفة diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.ar.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.ar.resx new file mode 100644 index 000000000..d62941070 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.ar.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + البحث + + + ابحث في البلاغات وملاحظات البلاغات والأفراد والوحدات والمزيد + + + بحث + + + البحث في + + + الكل + + + من + + + إلى + + + الترتيب حسب + + + الأكثر تطابقًا + + + الأحدث أولًا + + + الأقدم أولًا + + + تصدير CSV + + + نزّل كل النتائج المطابقة التي يمكنك فتحها (حتى {0}) كجدول بيانات. + + + عرض {0}–{1} من {2} + + + عرض {0}–{1} + + + لا توجد نتائج مطابقة لبحثك. + + + تحقق من الإملاء أو احذف كلمة أو وسّع نطاق التاريخ. + + + أدخل الكلمات التي تبحث عنها. + + + يجب أن تظهر كل كلمة في النتيجة. ضع العبارة بين علامتي اقتباس لمطابقتها تمامًا، مثل "إنذار عطل" "مبنى 4". اختر نوعًا وتواريخ للحصول على قائمة كاملة يمكنك تصديرها. + + + يجري إنشاء فهرس البحث الخاص بإدارتك. قد تكون النتائج غير مكتملة لبضع دقائق. + + + فهرس البحث غير متاح حاليًا. حاول مرة أخرى بعد بضع دقائق. + + + البحث غير متاح لحسابك. + + + هناك نتائج مطابقة أكثر مما يمكن عده هنا. اختر نوعًا أو نطاقًا زمنيًا للحصول على قائمة كاملة. + + + تعذرت قراءة تاريخ وتم تجاهله. استخدم أداة اختيار التاريخ. + + + السابق + + + التالي + + + البلاغات + + + التقارير (Records) + + + الأفراد + + + الوحدات + + + جهات الاتصال + + + الرسائل + + + الملاحظات + + + المستندات + + + المهام الميدانية + + + الفواتير + + + العروض + + + العقود + + + جداول الأسعار + + + أنواع الشهادات + + + بلاغ + + + تقرير + + + فرد + + + وحدة + + + جهة اتصال + + + رسالة + + + ملاحظة + + + مستند + + + مهمة ميدانية + + + فاتورة + + + عرض + + + عقد + + + جدول أسعار + + + نوع الشهادة + + + السجلات + + + البروتوكولات + + + التدريبات + + + التقويم + + + نقاط الاهتمام + + + الورديات + + + المجموعات والمحطات + + + سجل + + + بروتوكول + + + تدريب + + + حدث + + + نقطة اهتمام + + + وردية + + + مجموعة + + + المنشآت (الخطط المسبقة) + + + منشأة + + + البحث في كل النتائج + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.cs b/Core/Resgrid.Localization/Areas/User/Search/Search.cs new file mode 100644 index 000000000..5d65cc716 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Localization.Areas.User.Search +{ + /// Marker type used by ASP.NET Core localization for the search page resources. + public class Search + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.de.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.de.resx new file mode 100644 index 000000000..f9024e2da --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.de.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Suche + + + Einsätze, Einsatznotizen, Personen, Einheiten und mehr durchsuchen + + + Suchen + + + Suchen in + + + Alles + + + Von + + + Bis + + + Sortieren nach + + + Beste Übereinstimmung + + + Neueste zuerst + + + Älteste zuerst + + + CSV exportieren + + + Alle passenden Ergebnisse, die Sie öffnen dürfen (bis zu {0}), als Tabelle herunterladen. + + + Anzeige {0}–{1} von {2} + + + Anzeige {0}–{1} + + + Keine Ergebnisse für Ihre Suche. + + + Prüfen Sie die Schreibweise, entfernen Sie ein Wort oder erweitern Sie den Zeitraum. + + + Geben Sie die Suchbegriffe ein. + + + Jedes Wort muss im Ergebnis vorkommen. Setzen Sie einen Ausdruck in Anführungszeichen, um ihn genau zu finden, zum Beispiel "Störungsalarm" "Gebäude 4". Wählen Sie einen Typ und einen Zeitraum für eine vollständige, exportierbare Liste. + + + Der Suchindex Ihrer Abteilung wird gerade erstellt. Die Ergebnisse können einige Minuten lang unvollständig sein. + + + Der Suchindex ist derzeit nicht verfügbar. Versuchen Sie es in einigen Minuten erneut. + + + Die Suche ist für Ihr Konto nicht verfügbar. + + + Es gibt mehr Treffer, als hier gezählt werden können. Wählen Sie einen Typ oder einen Zeitraum für eine vollständige Liste. + + + Ein Datum konnte nicht gelesen werden und wurde ignoriert. Verwenden Sie die Datumsauswahl. + + + Zurück + + + Weiter + + + Einsätze + + + Berichte (Records) + + + Personal + + + Einheiten + + + Kontakte + + + Nachrichten + + + Notizen + + + Dokumente + + + Einsatzaufträge + + + Rechnungen + + + Angebote + + + Verträge + + + Tariftabellen + + + Zertifizierungsarten + + + Einsatz + + + Bericht + + + Person + + + Einheit + + + Kontakt + + + Nachricht + + + Notiz + + + Dokument + + + Einsatzauftrag + + + Rechnung + + + Angebot + + + Vertrag + + + Tariftabelle + + + Zertifizierungsart + + + Protokolle + + + Abfrageprotokolle + + + Ausbildungen + + + Kalender + + + Points of Interest + + + Schichten + + + Gruppen & Wachen + + + Protokoll + + + Abfrageprotokoll + + + Ausbildung + + + Termin + + + Point of Interest + + + Schicht + + + Gruppe + + + Objekte (Einsatzpläne) + + + Objekt + + + Alle Ergebnisse durchsuchen + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.el.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.el.resx new file mode 100644 index 000000000..7cd34c472 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.el.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Αναζήτηση + + + Αναζήτηση κλήσεων, σημειώσεων κλήσεων, ατόμων, μονάδων και άλλων + + + Αναζήτηση + + + Αναζήτηση σε + + + Όλα + + + Από + + + Έως + + + Ταξινόμηση κατά + + + Καλύτερη αντιστοιχία + + + Πρώτα τα νεότερα + + + Πρώτα τα παλαιότερα + + + Εξαγωγή CSV + + + Λήψη όλων των αποτελεσμάτων που ταιριάζουν και μπορείτε να ανοίξετε (έως {0}) ως υπολογιστικό φύλλο. + + + Εμφάνιση {0}–{1} από {2} + + + Εμφάνιση {0}–{1} + + + Δεν βρέθηκαν αποτελέσματα για την αναζήτησή σας. + + + Ελέγξτε την ορθογραφία, αφαιρέστε μια λέξη ή διευρύνετε το εύρος ημερομηνιών. + + + Εισαγάγετε τις λέξεις που αναζητάτε. + + + Κάθε λέξη πρέπει να υπάρχει στο αποτέλεσμα. Βάλτε μια φράση σε εισαγωγικά για ακριβή αντιστοίχιση, για παράδειγμα "συναγερμός βλάβης" "κτίριο 4". Επιλέξτε τύπο και ημερομηνίες για πλήρη λίστα προς εξαγωγή. + + + Το ευρετήριο αναζήτησης του τμήματός σας δημιουργείται. Τα αποτελέσματα μπορεί να είναι ελλιπή για λίγα λεπτά. + + + Το ευρετήριο αναζήτησης δεν είναι διαθέσιμο αυτή τη στιγμή. Δοκιμάστε ξανά σε λίγα λεπτά. + + + Η αναζήτηση δεν είναι διαθέσιμη για τον λογαριασμό σας. + + + Υπάρχουν περισσότερες αντιστοιχίες από όσες μπορούν να μετρηθούν εδώ. Επιλέξτε τύπο ή εύρος ημερομηνιών για πλήρη λίστα. + + + Μια ημερομηνία δεν ήταν δυνατό να διαβαστεί και αγνοήθηκε. Χρησιμοποιήστε την επιλογή ημερομηνίας. + + + Προηγούμενη + + + Επόμενη + + + Κλήσεις + + + Αναφορές (Records) + + + Προσωπικό + + + Μονάδες + + + Επαφές + + + Μηνύματα + + + Σημειώσεις + + + Έγγραφα + + + Αποστολές + + + Τιμολόγια + + + Προσφορές + + + Συμβάσεις + + + Τιμοκατάλογοι + + + Τύποι πιστοποίησης + + + Κλήση + + + Αναφορά + + + Άτομο + + + Μονάδα + + + Επαφή + + + Μήνυμα + + + Σημείωση + + + Έγγραφο + + + Αποστολή + + + Τιμολόγιο + + + Προσφορά + + + Σύμβαση + + + Τιμοκατάλογος + + + Τύπος πιστοποίησης + + + Καταγραφές + + + Πρωτόκολλα + + + Εκπαιδεύσεις + + + Ημερολόγιο + + + Σημεία ενδιαφέροντος + + + Βάρδιες + + + Ομάδες και σταθμοί + + + Καταγραφή + + + Πρωτόκολλο + + + Εκπαίδευση + + + Εκδήλωση + + + Σημείο ενδιαφέροντος + + + Βάρδια + + + Ομάδα + + + Ακίνητα (Προσχέδια) + + + Ακίνητο + + + Αναζήτηση σε όλα τα αποτελέσματα + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.en.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.en.resx new file mode 100644 index 000000000..5cfaba9f8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.en.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Search + + + Search calls, call notes, people, units and more + + + Search + + + Search in + + + Everything + + + From + + + To + + + Sort by + + + Best match + + + Newest first + + + Oldest first + + + Export CSV + + + Download every matching result you can open (up to {0}) as a spreadsheet. + + + Showing {0}–{1} of {2} + + + Showing {0}–{1} + + + No results match your search. + + + Check the spelling, remove a word or widen the date range. + + + Enter the words to look for. + + + Every word must appear somewhere in the result. Put a phrase in quotes to match it exactly, for example "trouble alarm" "building 4". Choose a type and dates for a complete list you can export. + + + Your department's search index is being built. Results may be incomplete for a few minutes. + + + The search index is not available right now. Try again in a few minutes. + + + Search is not available for your account. + + + There are more matches than can be counted here. Choose a type or a date range for a complete list. + + + A date could not be read and was ignored. Use the date picker. + + + Previous + + + Next + + + Calls + + + Reports (Records) + + + Personnel + + + Units + + + Contacts + + + Messages + + + Notes + + + Documents + + + Deployments + + + Invoices + + + Bids + + + Contracts + + + Rate Cards + + + Certification Types + + + Call + + + Report + + + Person + + + Unit + + + Contact + + + Message + + + Note + + + Document + + + Deployment + + + Invoice + + + Bid + + + Contract + + + Rate Card + + + Certification Type + + + Logs + + + Protocols + + + Trainings + + + Calendar + + + Points of Interest + + + Shifts + + + Groups & Stations + + + Log + + + Protocol + + + Training + + + Event + + + Point of Interest + + + Shift + + + Group + + + Occupancies (Pre-plans) + + + Occupancy + + + Search all results + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.es.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.es.resx new file mode 100644 index 000000000..ee0001042 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.es.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Búsqueda + + + Buscar llamadas, notas de llamadas, personas, unidades y más + + + Buscar + + + Buscar en + + + Todo + + + Desde + + + Hasta + + + Ordenar por + + + Mejor coincidencia + + + Más recientes primero + + + Más antiguos primero + + + Exportar CSV + + + Descargue como hoja de cálculo todos los resultados coincidentes que puede abrir (hasta {0}). + + + Mostrando {0}–{1} de {2} + + + Mostrando {0}–{1} + + + Ningún resultado coincide con su búsqueda. + + + Revise la ortografía, quite una palabra o amplíe el intervalo de fechas. + + + Escriba las palabras que desea buscar. + + + Cada palabra debe aparecer en el resultado. Ponga una frase entre comillas para buscarla exacta, por ejemplo "alarma de avería" "edificio 4". Elija un tipo y unas fechas para obtener una lista completa que pueda exportar. + + + Se está creando el índice de búsqueda de su departamento. Los resultados pueden estar incompletos durante unos minutos. + + + El índice de búsqueda no está disponible en este momento. Vuelva a intentarlo en unos minutos. + + + La búsqueda no está disponible para su cuenta. + + + Hay más coincidencias de las que se pueden contar aquí. Elija un tipo o un intervalo de fechas para obtener una lista completa. + + + No se pudo leer una fecha y se ha ignorado. Use el selector de fecha. + + + Anterior + + + Siguiente + + + Llamadas + + + Informes (Records) + + + Personal + + + Unidades + + + Contactos + + + Mensajes + + + Notas + + + Documentos + + + Despliegues + + + Facturas + + + Ofertas + + + Contratos + + + Tarifas + + + Tipos de certificación + + + Llamada + + + Informe + + + Persona + + + Unidad + + + Contacto + + + Mensaje + + + Nota + + + Documento + + + Despliegue + + + Factura + + + Oferta + + + Contrato + + + Tarifa + + + Tipo de certificación + + + Bitácoras + + + Protocolos + + + Capacitaciones + + + Calendario + + + Puntos de interés + + + Turnos + + + Grupos y estaciones + + + Bitácora + + + Protocolo + + + Capacitación + + + Evento + + + Punto de interés + + + Turno + + + Grupo + + + Ocupaciones (Preplanes) + + + Ocupación + + + Buscar en todos los resultados + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.fr.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.fr.resx new file mode 100644 index 000000000..a3d0bf6de --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.fr.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Recherche + + + Rechercher des appels, des notes d'appel, des personnes, des unités et plus + + + Rechercher + + + Rechercher dans + + + Tout + + + Du + + + Au + + + Trier par + + + Meilleure correspondance + + + Les plus récents d'abord + + + Les plus anciens d'abord + + + Exporter en CSV + + + Téléchargez sous forme de tableur tous les résultats correspondants que vous pouvez ouvrir (jusqu’à {0}). + + + Affichage de {0} à {1} sur {2} + + + Affichage de {0} à {1} + + + Aucun résultat ne correspond à votre recherche. + + + Vérifiez l'orthographe, retirez un mot ou élargissez la plage de dates. + + + Saisissez les mots à rechercher. + + + Chaque mot doit figurer dans le résultat. Mettez une expression entre guillemets pour la trouver telle quelle, par exemple "alarme dérangement" "bâtiment 4". Choisissez un type et des dates pour obtenir une liste complète à exporter. + + + L'index de recherche de votre service est en cours de création. Les résultats peuvent être incomplets pendant quelques minutes. + + + L'index de recherche n'est pas disponible pour le moment. Réessayez dans quelques minutes. + + + La recherche n'est pas disponible pour votre compte. + + + Il y a plus de correspondances que ce qui peut être compté ici. Choisissez un type ou une plage de dates pour une liste complète. + + + Une date n'a pas pu être lue et a été ignorée. Utilisez le sélecteur de date. + + + Précédent + + + Suivant + + + Appels + + + Rapports (Records) + + + Personnel + + + Unités + + + Contacts + + + Messages + + + Notes + + + Documents + + + Déploiements + + + Factures + + + Offres + + + Contrats + + + Grilles tarifaires + + + Types de certification + + + Appel + + + Rapport + + + Personne + + + Unité + + + Contact + + + Message + + + Note + + + Document + + + Déploiement + + + Facture + + + Offre + + + Contrat + + + Grille tarifaire + + + Type de certification + + + Journaux + + + Protocoles + + + Formations + + + Calendrier + + + Points d'intérêt + + + Quarts + + + Groupes et casernes + + + Journal + + + Protocole + + + Formation + + + Événement + + + Point d'intérêt + + + Quart + + + Groupe + + + Occupations (Plans d’intervention) + + + Occupation + + + Rechercher dans tous les résultats + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.it.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.it.resx new file mode 100644 index 000000000..d3d5fb08a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.it.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ricerca + + + Cerca chiamate, note delle chiamate, persone, unità e altro + + + Cerca + + + Cerca in + + + Tutto + + + Dal + + + Al + + + Ordina per + + + Migliore corrispondenza + + + Prima i più recenti + + + Prima i meno recenti + + + Esporta CSV + + + Scarica come foglio di calcolo tutti i risultati corrispondenti che puoi aprire (fino a {0}). + + + Visualizzati {0}–{1} di {2} + + + Visualizzati {0}–{1} + + + Nessun risultato corrisponde alla ricerca. + + + Controlla l'ortografia, rimuovi una parola o amplia l'intervallo di date. + + + Inserisci le parole da cercare. + + + Ogni parola deve comparire nel risultato. Metti una frase tra virgolette per trovarla esatta, ad esempio "allarme guasto" "edificio 4". Scegli un tipo e delle date per ottenere un elenco completo da esportare. + + + L'indice di ricerca del tuo dipartimento è in fase di creazione. I risultati potrebbero essere incompleti per qualche minuto. + + + L'indice di ricerca non è al momento disponibile. Riprova tra qualche minuto. + + + La ricerca non è disponibile per il tuo account. + + + Ci sono più corrispondenze di quante se ne possano contare qui. Scegli un tipo o un intervallo di date per un elenco completo. + + + Una data non è stata riconosciuta ed è stata ignorata. Usa il selettore di data. + + + Precedente + + + Successiva + + + Chiamate + + + Rapporti (Records) + + + Personale + + + Unità + + + Contatti + + + Messaggi + + + Note + + + Documenti + + + Impieghi + + + Fatture + + + Offerte + + + Contratti + + + Tariffari + + + Tipi di certificazione + + + Chiamata + + + Rapporto + + + Persona + + + Unità + + + Contatto + + + Messaggio + + + Nota + + + Documento + + + Impiego + + + Fattura + + + Offerta + + + Contratto + + + Tariffario + + + Tipo di certificazione + + + Log + + + Protocolli + + + Formazioni + + + Calendario + + + Punti di interesse + + + Turni + + + Gruppi e sedi + + + Log + + + Protocollo + + + Formazione + + + Evento + + + Punto di interesse + + + Turno + + + Gruppo + + + Occupazioni (Piani preincidente) + + + Occupazione + + + Cerca tra tutti i risultati + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.pl.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.pl.resx new file mode 100644 index 000000000..86e010a08 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.pl.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Wyszukiwanie + + + Szukaj zgłoszeń, notatek zgłoszeń, osób, jednostek i nie tylko + + + Szukaj + + + Szukaj w + + + Wszystko + + + Od + + + Do + + + Sortuj według + + + Najlepsze dopasowanie + + + Najnowsze najpierw + + + Najstarsze najpierw + + + Eksportuj CSV + + + Pobierz jako arkusz kalkulacyjny wszystkie pasujące wyniki, które możesz otworzyć (do {0}). + + + Wyświetlanie {0}–{1} z {2} + + + Wyświetlanie {0}–{1} + + + Brak wyników pasujących do wyszukiwania. + + + Sprawdź pisownię, usuń jedno słowo lub poszerz zakres dat. + + + Wpisz słowa, których szukasz. + + + Każde słowo musi wystąpić w wyniku. Ujmij frazę w cudzysłów, aby wyszukać ją dokładnie, na przykład "alarm usterki" "budynek 4". Wybierz typ i daty, aby otrzymać pełną listę do eksportu. + + + Indeks wyszukiwania Twojej jednostki jest w trakcie tworzenia. Przez kilka minut wyniki mogą być niepełne. + + + Indeks wyszukiwania jest teraz niedostępny. Spróbuj ponownie za kilka minut. + + + Wyszukiwanie nie jest dostępne dla Twojego konta. + + + Jest więcej dopasowań, niż można tu policzyć. Wybierz typ lub zakres dat, aby otrzymać pełną listę. + + + Nie udało się odczytać daty, więc została pominięta. Użyj selektora daty. + + + Poprzednia + + + Następna + + + Zgłoszenia + + + Raporty (Records) + + + Personel + + + Jednostki + + + Kontakty + + + Wiadomości + + + Notatki + + + Dokumenty + + + Wyjazdy + + + Faktury + + + Oferty + + + Umowy + + + Cenniki + + + Rodzaje certyfikatów + + + Zgłoszenie + + + Raport + + + Osoba + + + Jednostka + + + Kontakt + + + Wiadomość + + + Notatka + + + Dokument + + + Wyjazd + + + Faktura + + + Oferta + + + Umowa + + + Cennik + + + Rodzaj certyfikatu + + + Dzienniki + + + Protokoły + + + Szkolenia + + + Kalendarz + + + Punkty zainteresowania + + + Zmiany + + + Grupy i strażnice + + + Dziennik + + + Protokół + + + Szkolenie + + + Wydarzenie + + + Punkt zainteresowania + + + Zmiana + + + Grupa + + + Obiekty (Plany operacyjne) + + + Obiekt + + + Szukaj we wszystkich wynikach + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.sv.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.sv.resx new file mode 100644 index 000000000..f2906a591 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.sv.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Sök + + + Sök larm, larmanteckningar, personer, enheter och mer + + + Sök + + + Sök i + + + Allt + + + Från + + + Till + + + Sortera efter + + + Bästa träff + + + Nyaste först + + + Äldsta först + + + Exportera CSV + + + Ladda ned alla matchande resultat som du kan öppna (upp till {0}) som ett kalkylark. + + + Visar {0}–{1} av {2} + + + Visar {0}–{1} + + + Inga resultat matchar din sökning. + + + Kontrollera stavningen, ta bort ett ord eller utöka datumintervallet. + + + Skriv orden du vill söka efter. + + + Varje ord måste finnas i resultatet. Sätt en fras inom citattecken för att matcha den exakt, till exempel "fellarm" "byggnad 4". Välj en typ och datum för att få en fullständig lista som du kan exportera. + + + Sökindexet för din organisation byggs. Resultaten kan vara ofullständiga i några minuter. + + + Sökindexet är inte tillgängligt just nu. Försök igen om några minuter. + + + Sökning är inte tillgänglig för ditt konto. + + + Det finns fler träffar än vad som kan räknas här. Välj en typ eller ett datumintervall för en fullständig lista. + + + Ett datum kunde inte läsas och ignorerades. Använd datumväljaren. + + + Föregående + + + Nästa + + + Larm + + + Rapporter (Records) + + + Personal + + + Enheter + + + Kontakter + + + Meddelanden + + + Anteckningar + + + Dokument + + + Insatser + + + Fakturor + + + Anbud + + + Avtal + + + Prislistor + + + Certifieringstyper + + + Larm + + + Rapport + + + Person + + + Enhet + + + Kontakt + + + Meddelande + + + Anteckning + + + Dokument + + + Insats + + + Faktura + + + Anbud + + + Avtal + + + Prislista + + + Certifieringstyp + + + Loggar + + + Protokoll + + + Utbildningar + + + Kalender + + + Intressepunkter + + + Skift + + + Grupper och stationer + + + Logg + + + Protokoll + + + Utbildning + + + Händelse + + + Intressepunkt + + + Skift + + + Grupp + + + Objekt (Insatsplaner) + + + Objekt + + + Sök bland alla resultat + + diff --git a/Core/Resgrid.Localization/Areas/User/Search/Search.uk.resx b/Core/Resgrid.Localization/Areas/User/Search/Search.uk.resx new file mode 100644 index 000000000..749b6481c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Search/Search.uk.resx @@ -0,0 +1,228 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Пошук + + + Шукайте виклики, нотатки викликів, людей, одиниці та інше + + + Шукати + + + Шукати в + + + Усе + + + З + + + По + + + Сортувати за + + + Найкращий збіг + + + Спочатку нові + + + Спочатку старі + + + Експортувати CSV + + + Завантажте у вигляді таблиці всі відповідні результати, які ви можете відкрити (до {0}). + + + Показано {0}–{1} з {2} + + + Показано {0}–{1} + + + Немає результатів за вашим запитом. + + + Перевірте написання, приберіть слово або розширте діапазон дат. + + + Введіть слова для пошуку. + + + Кожне слово має бути в результаті. Візьміть фразу в лапки, щоб знайти її точно, наприклад "тривога несправності" "корпус 4". Виберіть тип і дати, щоб отримати повний список для експорту. + + + Пошуковий індекс вашого підрозділу створюється. Кілька хвилин результати можуть бути неповними. + + + Пошуковий індекс зараз недоступний. Спробуйте ще раз за кілька хвилин. + + + Пошук недоступний для вашого облікового запису. + + + Збігів більше, ніж можна тут порахувати. Виберіть тип або діапазон дат для повного списку. + + + Не вдалося прочитати дату, її проігноровано. Скористайтеся вибором дати. + + + Назад + + + Далі + + + Виклики + + + Звіти (Records) + + + Персонал + + + Одиниці + + + Контакти + + + Повідомлення + + + Нотатки + + + Документи + + + Розгортання + + + Рахунки + + + Пропозиції + + + Договори + + + Тарифні картки + + + Типи сертифікацій + + + Виклик + + + Звіт + + + Особа + + + Одиниця + + + Контакт + + + Повідомлення + + + Нотатка + + + Документ + + + Розгортання + + + Рахунок + + + Пропозиція + + + Договір + + + Тарифна картка + + + Тип сертифікації + + + Журнали + + + Протоколи + + + Навчання + + + Календар + + + Точки інтересу + + + Зміни + + + Групи та станції + + + Журнал + + + Протокол + + + Навчання + + + Подія + + + Точка інтересу + + + Зміна + + + Група + + + Об’єкти (Оперативні плани) + + + Об’єкт + + + Шукати серед усіх результатів + + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx index bd6798388..afe65e75a 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx @@ -8,6 +8,363 @@ الأمان والصلاحيات يمكنك هنا تعيين الصلاحيات لقسمك، مثل تحديد من يمكنه إنشاء البلاغات أو من هو مخوّل بإنشاء المستخدمين وإزالتهم. ستسري التغييرات على الصلاحيات عند تسجيل الدخول التالي إلى تطبيق Resgrid. سجلات التدقيق + التصفية حسب نوع التدقيق + جميع أنواع التدقيق + ابحث باسم المستخدم، أو معرف المستخدم أو التدقيق، أو عنوان البريد الإلكتروني، أو التاريخ/الوقت، أو نوع التدقيق. يُطبَّق البحث والفرز ضمن نوع التدقيق المحدد. + البحث في سجلات التدقيق: + الاسم أو المعرف أو البريد الإلكتروني أو التاريخ/الوقت أو النوع + الطابع الزمني + النوع + سُجّل بواسطة + النتيجة + الرسالة + مصطلحات البحث + الإجراءات + ناجح + فاشل + عرض + عرض _START_ إلى _END_ من أصل _TOTAL_ إدخال + عرض 0 إلى 0 من أصل 0 إدخال + (تمت التصفية من إجمالي _MAX_ إدخال) + عرض _MENU_ إدخالات + جارٍ التحميل... + لا توجد إدخالات في سجل التدقيق + لم يتم العثور على إدخالات مطابقة في سجل التدقيق + الأول + الأخير + التالي + السابق + : فعّل لفرز العمود تصاعدياً + : فعّل لفرز العمود تنازلياً + النظام + غير معروف + عرض سجل التدقيق + سجل التدقيق + معرف سجل التدقيق: + معرف القسم: + نوع التدقيق: + معرف نوع السجل: + وصف النوع: + النتيجة: + سُجّل بواسطة: + معرف المستخدم: + وقت التسجيل (التوقيت المحلي): + وقت التسجيل (UTC): + عنوان IP: + اسم الخادم: + معرف الكائن: + معرف قسم الكائن: + وكيل المستخدم: + الرسالة: + البيانات: + غير مسجّل + تم تغيير إعدادات القسم + تمت إضافة المستخدم + تمت إزالة المستخدم + تمت إضافة المجموعة + تمت إزالة المجموعة + تم تغيير المجموعة + تمت إضافة الوحدة + تمت إزالة الوحدة + تم تغيير الوحدة + تم تحديث الملف الشخصي + تم تغيير الصلاحيات + تم تحديث الاشتراك + تم إنشاء الاشتراك + تم إلغاء الاشتراك + تم تحديث معلومات فواتير الاشتراك + تمت إعادة تفعيل البلاغ + تم حذف حساب المستخدم + تم تعديل اشتراك الإضافة + تم طلب حذف القسم + تم إلغاء طلب حذف القسم + تم حذف الوردية الثابتة + تم تحديث الوردية الثابتة + تمت إضافة الحالة المخصصة + تمت إزالة الحالة المخصصة + تم تحديث الحالة المخصصة + تم تحديث تفاصيل الحالة المخصصة + تمت إضافة نوع البلاغ + تم تعديل نوع البلاغ + تمت إزالة نوع البلاغ + تمت إضافة أولوية البلاغ + تم تعديل أولوية البلاغ + تمت إزالة أولوية البلاغ + تمت إضافة نوع الوحدة + تم تعديل نوع الوحدة + تمت إزالة نوع الوحدة + تمت إضافة نوع الشهادة + تم تعديل نوع الشهادة + تمت إزالة نوع الشهادة + تمت إضافة فئة المستندات + تم تعديل فئة المستندات + تمت إزالة فئة المستندات + تمت إضافة المستند + تم تعديل المستند + تمت إزالة المستند + تمت إضافة فئة الملاحظات + تم تعديل فئة الملاحظات + تمت إزالة فئة الملاحظات + تمت إضافة الملاحظة + تم تعديل الملاحظة + تمت إزالة الملاحظة + تمت إضافة جهة الاتصال + تم تعديل جهة الاتصال + تمت إزالة جهة الاتصال + تمت إضافة فئة جهات الاتصال + تم تعديل فئة جهات الاتصال + تمت إزالة فئة جهات الاتصال + تمت إضافة نوع ملاحظة جهة الاتصال + تم تعديل نوع ملاحظة جهة الاتصال + تمت إزالة نوع ملاحظة جهة الاتصال + تم إنشاء سير العمل + تم تحديث سير العمل + تم حذف سير العمل + تمت إضافة خطوة سير العمل + تم تحديث خطوة سير العمل + تم حذف خطوة سير العمل + تمت إضافة بيانات اعتماد سير العمل + تم تحديث بيانات اعتماد سير العمل + تم حذف بيانات اعتماد سير العمل + تم إرسال رمز التحقق من جهة الاتصال + تم تأكيد التحقق من جهة الاتصال + فشل التحقق من جهة الاتصال + تم تفعيل المصادقة الثنائية + تم تعطيل المصادقة الثنائية + تم التحقق من تسجيل الدخول بالمصادقة الثنائية + تم استخدام رمز استرداد المصادقة الثنائية + تم التحقق الإضافي بالمصادقة الثنائية + تم إنشاء إعداد SSO + تم تحديث إعداد SSO + تم حذف إعداد SSO + نجح تسجيل الدخول عبر SSO + فشل تسجيل الدخول عبر SSO + تم تزويد مستخدم SSO + تم إنشاء مستخدم SCIM + تم تحديث مستخدم SCIM + تم إلغاء تفعيل مستخدم SCIM + تم حذف مستخدم SCIM + فشلت مصادقة SCIM + تمت إعادة تفعيل مستخدم SCIM + تم سرد مجموعات SCIM + تم سرد مستخدمي SCIM + تم استرجاع مستخدم SCIM + تم تزويد رمز حامل SCIM + تم تدوير رمز حامل SCIM + تم إنشاء تعريف UDF + تم تحديث تعريف UDF + تم حذف تعريف UDF + تمت إضافة حقل UDF + تم تحديث حقل UDF + تمت إزالة حقل UDF + تم حفظ قيم حقول UDF + تم إنشاء المسار + تم تحديث المسار + تم حذف المسار + تم بدء المسار + تم إكمال المسار + تم إلغاء المسار + تم إيقاف المسار مؤقتاً + تم استئناف المسار + تم تسجيل الوصول إلى محطة المسار + تم تسجيل المغادرة من محطة المسار + تم تخطي محطة المسار + تم اكتشاف انحراف عن المسار + تم تأكيد العلم بالانحراف عن المسار + تم إنشاء تكوين مؤقت تسجيل الحضور + تم تحديث تكوين مؤقت تسجيل الحضور + تم حذف تكوين مؤقت تسجيل الحضور + تم إنشاء تجاوز مؤقت تسجيل الحضور + تم تحديث تجاوز مؤقت تسجيل الحضور + تم حذف تجاوز مؤقت تسجيل الحضور + تم تسجيل الحضور + تم تفعيل مؤقت تسجيل الحضور على البلاغ + تم تعطيل مؤقت تسجيل الحضور على البلاغ + تم تسجيل الحضور في حدث التقويم + تم تسجيل الانصراف من حدث التقويم + تم تحديث أوقات تسجيل الحضور في التقويم + تم حذف تسجيل الحضور في التقويم + تم تسجيل الحضور في التقويم بواسطة المسؤول + تم إنشاء سجل التشغيل + تم حذف سجل التشغيل + تم إنشاء اختبار الاتصال + تم تحديث اختبار الاتصال + تم حذف اختبار الاتصال + تم بدء تشغيل اختبار الاتصال + تم إنشاء مصدر تنبيهات الطقس + تم تحديث مصدر تنبيهات الطقس + تم حذف مصدر تنبيهات الطقس + تم تفعيل مصدر تنبيهات الطقس + تم تعطيل مصدر تنبيهات الطقس + تم إنشاء منطقة تنبيهات الطقس + تم تحديث منطقة تنبيهات الطقس + تم حذف منطقة تنبيهات الطقس + تم تفعيل منطقة تنبيهات الطقس + تم تعطيل منطقة تنبيهات الطقس + تم تغيير إعدادات تنبيهات الطقس + تم تغيير علامة الميزة + تم تغيير تجاوز علامة الميزة + تم إنشاء جهاز تتبع الوحدة + تم تحديث جهاز تتبع الوحدة + تم تعطيل جهاز تتبع الوحدة + تم حذف جهاز تتبع الوحدة + تم إنشاء بيانات اعتماد تتبع الوحدة + تم تدوير بيانات اعتماد تتبع الوحدة + تم إلغاء صلاحية بيانات اعتماد تتبع الوحدة + تم تنفيذ حذف القسم + تم حذف رسالة المحادثة بواسطة المراجع + تم كتم مستخدم المحادثة + تم إلغاء كتم مستخدم المحادثة + تم حظر مستخدم المحادثة + تم إلغاء حظر مستخدم المحادثة + تم قفل قناة المحادثة + تم إلغاء قفل قناة المحادثة + تمت أرشفة قناة المحادثة + تم حل بلاغ المحادثة + تم تغيير إعدادات المحادثة + تم طلب تصدير المحادثة + تم تنزيل تصدير المحادثة + تم تقديم بلاغ مراجعة المحتوى + تمت إعادة فتح طلب مراجعة المحتوى + تم إكمال طلب مراجعة المحتوى + تم تنزيل دليل مراجعة المحتوى + تمت إعادة تعيين كلمة المرور بواسطة المسؤول + تم إلغاء صلاحية جلسات تسجيل دخول المستخدم + تم تغيير استثناءات التحقق الإضافي لحماية البيانات + تمت إضافة الخطة المسبقة لجهة الاتصال + تم تحديث الخطة المسبقة لجهة الاتصال + تمت إزالة الخطة المسبقة لجهة الاتصال + تمت إضافة مرفق جهة الاتصال + تمت إزالة مرفق جهة الاتصال + تمت إضافة تعريف قائمة التحقق + تم تحديث تعريف قائمة التحقق + تم نشر تعريف قائمة التحقق + تم إيقاف استخدام تعريف قائمة التحقق + تمت إزالة تعريف قائمة التحقق + تم بدء تنفيذ قائمة التحقق + تم حفظ تقدم قائمة التحقق + تم تقديم تنفيذ قائمة التحقق + تم إقرار الشاهد لقائمة التحقق + تمت إضافة ملف قائمة التحقق + تمت إزالة ملف قائمة التحقق + تمت إضافة جدول قائمة التحقق + تم تحديث جدول قائمة التحقق + لم يُنفَّذ الفحص المجدول لقائمة التحقق + تم تخطي الفحص المجدول لقائمة التحقق + تم تحديث إعدادات تذكيرات قوائم التحقق + تم تغيير أمر العمل + تم تغيير المخزون + تم تغيير ملف الفوترة + تم تغيير بطاقة الأسعار + تم إنشاء الفاتورة + تم تحديث الفاتورة + تم إرسال الفاتورة + تم إبطال الفاتورة + تم تسجيل دفعة الفاتورة + تم تغيير هوية الفوترة للقسم + تم ربط حساب الدفع + تم فصل حساب الدفع + تم إلغاء صلاحية ربط حساب الدفع + إجراء مطلوب لحساب الدفع + تم إنشاء طلب دفع الفاتورة + تم رد دفعة الفاتورة + تم الاعتراض على دفعة الفاتورة + تم رفض Webhook الدفع + فشل طلب دفع الفاتورة + انتهت صلاحية طلب دفع الفاتورة + تمت إضافة الشهادة + تم تحديث الشهادة + تمت إزالة الشهادة + تم تغيير حالة الشهادة + تم التحقق من الشهادة + تمت إضافة ساعات اعتماد الشهادة + تمت إزالة ساعات اعتماد الشهادة + تم تغيير متطلب الشهادة للدور + تم تغيير إعدادات الشهادات للقسم + تمت إضافة عضو إلى الدور + تمت إزالة عضو من الدور + تمت إزالة عضو من الدور بسبب الشهادة + تمت إضافة شهادة الوحدة + تم تحديث شهادة الوحدة + تمت إزالة شهادة الوحدة + تم تغيير حالة شهادة الوحدة + تم إنشاء الانتشار + تم تحديث الانتشار + تم تغيير حالة الانتشار + تم تغيير تشكيل الانتشار + تم تغيير معدات الانتشار + تمت إضافة مرفق الانتشار + تمت إزالة مرفق الانتشار + تم إنشاء تقرير الوقت + تم تحديث تقرير الوقت + تم تقديم تقرير الوقت + تمت الموافقة على تقرير الوقت + تم إبطال تقرير الوقت + تمت إضافة مصروف الانتشار + تم تحديث مصروف الانتشار + تمت إزالة مصروف الانتشار + تم إنشاء جدول الأسعار + تم تحديث جدول الأسعار + تم حذف جدول الأسعار + تم تغيير بند جدول الأسعار + تم تغيير علاوة السعر + تم إنشاء عقد الخدمة + تم تحديث عقد الخدمة + تم تغيير حالة عقد الخدمة + تم حذف عقد الخدمة + تمت إضافة مستند الامتثال + تم تحديث مستند الامتثال + تمت إزالة مستند الامتثال + تم إنشاء العرض + تم تحديث العرض + تم إرسال العرض + تم قبول العرض + تم رفض العرض + تم سحب العرض + انتهت صلاحية العرض + تم تحويل العرض + تم حذف العرض + تمت فوترة تقرير الوقت + تم توليد فاتورة الانتشار + تم تغيير ملف وكالة Cal OES MARS + تم تغيير ملف مورد Cal OES MARS + تم تغيير ملف أسعار Cal OES MARS + تم بناء مسودة أسعار Cal OES MARS + تمت مراجعة أسعار Cal OES MARS + تم تغيير اتفاقية Cal OES MARS + تمت ملاحظة اتفاقية Cal OES MARS + تم إعداد عنصر عمل Cal OES MARS + تم التحقق من صحة عنصر عمل Cal OES MARS + تم حساب تعويض Cal OES MARS + تم فتح عنصر عمل Cal OES MARS للتسليم + تمت ملاحظة الحالة الخارجية في Cal OES MARS + تمت الموافقة على فاتورة Cal OES MARS + تم رفض فاتورة Cal OES MARS + تمت تسوية دفعة Cal OES MARS + تم حذف عنصر عمل Cal OES MARS + تم تغيير ملف صاحب العمل في القوى العاملة + تم تغيير المنشأة في القوى العاملة + تم تغيير فترة العمل في القوى العاملة + تم تغيير التعويض في القوى العاملة + تم استيراد بيانات الأجور السنوية في القوى العاملة + تم تغيير المعلومات الديموغرافية لبيانات الأجور + تم إنشاء تقرير بيانات الأجور + تم التحقق من صحة تقرير بيانات الأجور + تم تجميد تقرير بيانات الأجور + تم تصدير تقرير بيانات الأجور + تم تعليم تقرير بيانات الأجور كمصادق عليه + تم تصحيح تقرير بيانات الأجور + تم تغيير ملف تكلفة المورد + تم تغيير استخدام المورد + تم إنشاء عملية حساب التكاليف الميدانية + تم تجميد عملية حساب التكاليف الميدانية + تمت إعادة تفعيل المستخدم + تم تغيير تكوين القسم + تم تغيير مراجعة Admin Assist + تم الوصول إلى تشخيصات Admin Assist + تم تحديث إعدادات الإرسال بالذكاء الاصطناعي + تم الوصول إلى خطة Admin Assist + تم تغيير سياسة الأمان الصلاحية ملاحظة القيمة @@ -32,7 +389,13 @@ الأدوار لا توجد أدوار غير قابل للتطبيق + الجميع + مدراء القسم + مدراء القسم والمجموعات + مدراء القسم وأدوار محددة + مدراء القسم والمجموعات وأدوار محددة الأمان والصلاحيات + الرئيسية يمكنك هنا تعيين الصلاحيات لقسمك، مثل تحديد من يمكنه إنشاء البلاغات أو من هو مخوّل بإنشاء المستخدمين وإزالتهم. ستسري التغييرات على الصلاحيات عند تسجيل الدخول التالي إلى تطبيق Resgrid. من يمكنه إضافة مستخدمين يحدد هذا الخيار من يمكنه إضافة مستخدمين/أفراد للقسم. افتراضياً يستطيع مدراء القسم فقط (والعضو المُدير) إضافة مستخدمين. يمكن السماح لمدراء المجموعات أيضاً بإضافة مستخدمين (مقيّد بالمجموعة التي يديرونها فقط). @@ -134,6 +497,15 @@ SCIM تأكيد الحذف هل أنت متأكد من حذف إعداد SSO الخاص بـ {0}؟ لا يمكن التراجع عن هذا الإجراء. + نوع الموفر غير صالح. + يوجد بالفعل إعداد SSO من نوع {0}. استخدم «تعديل» لتغييره. + معرف عميل OIDC مطلوب. + يجب أن تكون الجهة المُصدِرة لـ OIDC رابط HTTPS صالحاً. + شهادة توقيع IdP مطلوبة للتحقق من صحة تأكيدات SAML. + يجب أن يكون عنوان URL لتسجيل الدخول الموحد لدى موفر الهوية رابط HTTPS صالحاً. + تم إنشاء إعداد SSO من نوع {0} بنجاح. + تم تحديث إعداد SSO بنجاح. + تم حذف إعداد SSO من نوع {0}. إلغاء حذف إعداد SSO جديد @@ -143,6 +515,8 @@ الخطوة 1 الموفر والإعدادات الأساسية بروتوكول موفر الهوية + OIDC (OpenID Connect) — Microsoft Entra وOkta وGoogle وAuth0 + SAML 2.0 — معظم موفري الهوية في المؤسسات والجهات الحكومية تفعيل إعداد SSO هذا يكون إعداد واحد فقط لكل نوع موفر نشطاً في وقت واحد. السماح بتسجيل الدخول بكلمة مرور محلية @@ -152,6 +526,7 @@ تفعيل إدارة هويات SCIM 2.0 يتيح لموفر هويتك إنشاء/تحديث/تعطيل الأعضاء تلقائياً. بعد الحفظ انتقل إلى صفحة إعداد SCIM لإنشاء رمز الحامل. الرتبة الافتراضية للمستخدمين المُزوَّدين تلقائياً + (لا توجد رتبة افتراضية) اختياري. يُطبّق فقط عند إنشاء عضو جديد عبر التزويد التلقائي. الخطوة 2 — OIDC إعدادات OpenID Connect @@ -180,6 +555,13 @@ سيجلب Resgrid هذا الرابط بشكل دوري للبقاء محدّثاً بشهادات IdP. رابط ACS (المخزّن) يُملأ تلقائياً من الرابط المُنشأ أعلاه. عدّله فقط إذا أعددت رابط ACS مختلفاً في موفر هويتك. + عنوان URI لإعادة التوجيه لتسجيل الدخول الذي يبدأ من Resgrid (أدخله في موفر الهوية) + أضفه إلى جانب أي عنوان URI لإعادة التوجيه سجلته من قبل؛ تواصل الإصدارات الأقدم من تطبيقات Resgrid استخدام عناوينها. + عناوين URI لإعادة التوجيه لتسجيل الدخول في تطبيقات Resgrid (أدخل كلًّا منها في موفر الهوية) + يعود كل تطبيق، وكل إصدار ويب لتطبيق، إلى عنوانه الخاص، لذا سجّلها كلها. تستخدمها الإصدارات الأقدم من التطبيقات، والتطبيقات التي لا يبدأ تسجيل دخولها من Resgrid. + {0} (الويب) + عنوان URL لتسجيل الدخول الموحد لدى موفر الهوية + عنوان تسجيل الدخول عبر SAML لدى موفر الهوية (ربط HTTP-Redirect). مطلوب لتسجيل الدخول الذي يبدأ من تطبيقات Resgrid وموقعها على الويب. شهادة توقيع IdP (PEM) الشهادة مخزّنة مشفّرة في حالة السكون. اتركها فارغة للاحتفاظ بالشهادة الحالية. @@ -298,6 +680,9 @@ نطاقات IP المسموح بها (CIDR) كتلة CIDR واحدة لكل سطر أو مفصولة بفواصل. فارغ = السماح للجميع. سيُرفض تسجيل الدخول من خارج هذه النطاقات. مستوى تصنيف البيانات + غير مصنّف + CUI - معلومات غير مصنّفة خاضعة للرقابة + سري يُستخدم لتقارير الامتثال وسجلات التدقيق. سياسة كلمة المرور تنطبق سياسات كلمة المرور على تسجيلات الدخول المحلية (غير SSO) فقط. إذا فعّلت "اشتراط SSO" أعلاه فهذه الإعدادات لن يكون لها تأثير. @@ -325,6 +710,8 @@ يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل. يجب أن تتكون كلمة المرور من {0} أحرف على الأقل. لا يمكن أن يكون الحد الأدنى لطول كلمة المرور أقل من الإعداد الافتراضي للنظام وهو 8 أحرف. + حذف إدخالات السجل + من يُسمح له في قسمك بحذف إدخالات السجل Use Calendar Sync Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. Dispatch App Login @@ -410,4 +797,190 @@ يحدد من يمكنه تحويل المخزون بين المواقع. يُسمح افتراضيًا لمسؤولي الإدارة. + + طرق العامل الثاني + + + اختر طرق التحقق التي تُحتسب مصادقة متعددة العوامل في هذه الإدارة. تُقبل رموز تطبيق المصادقة (TOTP) دائمًا، لذلك لا يؤدي إيقاف أي طريقة إلى منع أحد من الدخول؛ ويُطلب من الأعضاء الذين استخدموها التحقق مرة أخرى. + + + يمكن لعضو الإدارة المسؤول فقط تغيير هذه الإعدادات. + + + مفاتيح المرور غير متاحة بعد على هذا النظام. تسري هذه الخيارات عندما تصبح متاحة. + + + غير متاح بعد + + + قبول مفاتيح المرور لتسجيل الدخول وعمليات التحقق الأمنية + + + يُحتسب مفتاح المرور المسجل في التطبيق نفسه مصادقةً متعددة العوامل لتسجيل الدخول وتبديل الإدارات والإجراءات الحساسة. + + + قبول مفاتيح المرور للبيانات المحمية + + + يُحتسب مفتاح المرور مصادقةً متعددة العوامل لعرض البيانات المحمية وتعديلها. يؤدي تغيير هذا الإعداد إلى إنهاء الوصول الحالي إلى البيانات المحمية، فيتحقق الأعضاء مرة أخرى. + + + قبول الموافقة من تطبيق Responder + + + حيث تُقبل مفاتيح المرور، يمكن للعضو الموافقة على طلب تسجيل دخول أو وصول إلى بيانات محمية باستخدام مفتاح المرور في تطبيق Responder لديه. لا تُستخدم أبدًا للتغييرات الأمنية. يؤدي تغيير هذا الإعداد إلى إنهاء الوصول الحالي إلى البيانات المحمية. + + + قبول المصادقة متعددة العوامل لدى موفر الهوية لتسجيل الدخول وعمليات التحقق الأمنية + + + يتطلب ربطًا مختبرًا للمصادقة متعددة العوامل في إعداد تسجيل الدخول الموحد. + + + قبول المصادقة متعددة العوامل لدى موفر الهوية للبيانات المحمية + + + يتطلب ربطًا مختبرًا للمصادقة متعددة العوامل في إعداد تسجيل الدخول الموحد. يؤدي تغيير هذا الإعداد إلى إنهاء الوصول الحالي إلى البيانات المحمية. + + + لا يحتوي إعداد تسجيل الدخول الموحد لديك على ربط مختبر للمصادقة متعددة العوامل. احفظ ربطًا وأكمل اختباره قبل قبول المصادقة متعددة العوامل لدى موفر الهوية. + + + لقبول المصادقة متعددة العوامل لدى موفر الهوية، تحقق أولًا باستخدام تطبيق المصادقة أو مفتاح مرور. لا يمكن للمصادقة متعددة العوامل لدى موفر الهوية الموافقة على هذا التغيير. + + + السماح لتحقق حديث عند تسجيل الدخول بفتح البيانات المحمية + + + لا يحتاج الأعضاء الذين أكملوا المصادقة متعددة العوامل عند تسجيل الدخول إلى التحقق مرة أخرى لعرض البيانات المحمية ضمن نافذة التحقق. يؤدي تغيير هذا الإعداد إلى إنهاء الوصول الحالي إلى البيانات المحمية. + + + السماح لإلغاء قفل جهاز مشترك بفتح البيانات المحمية + + + على الأجهزة اللوحية المشتركة في المركبات ومحطات العمل، يُحتسب تحقق المشغّل الحديث عند إلغاء القفل لعرض البيانات المحمية. يؤدي تغيير هذا الإعداد إلى إنهاء الوصول الحالي إلى البيانات المحمية. + + + الأجهزة المشتركة في المركبات ومحطات العمل + + + تُقفَل الأجهزة اللوحية المشتركة في المركبات ومحطات عمل الإرسال عندما لا يستخدمها أحد، وتنتهي جلستها بانتهاء المناوبة. يلغي المشغّلون القفل باستخدام تطبيق المصادقة أو مفتاح المرور أو موافقة Responder الخاصة بهم، ولا يُستخدم أبدًا كلمة مرور محفوظة على الجهاز. تنطبق القيمة الأكثر صرامة هنا أيضًا على الجلسات الجارية. + + + يمكن لعضو الإدارة المسؤول فقط تغيير سياسة الأجهزة المشتركة. + + + وضع الأجهزة المشتركة غير متاح في هذا النشر بعد. تُحفظ هذه القيم، ولا يمكن فرضه على تطبيق آخر حتى يصبح متاحًا. + + + القفل بعد هذا العدد من دقائق الخمول + + + من 1 إلى {0} دقيقة. يُحتسب نشاط المشغّل نفسه فقط؛ ولا تُحتسب التحديثات في الخلفية والتنبيهات الواردة. + + + إنهاء المناوبة بعد هذا العدد من الساعات + + + من 1 إلى {0} ساعة بعد تسجيل الدخول، بغض النظر عن النشاط. تسجّل المناوبة التالية الدخول من جديد. + + + استخدام الوضع المشترك دائمًا لـ + + + تُقفَل جلسات هذه التطبيقات في هذه الإدارة دائمًا عند الخمول وتنتهي بانتهاء المناوبة، مهما كان إعداد التثبيت. وتُحتسب أيضًا عمليات تسجيل الدخول التي لا تحدد التطبيق، لذا تُقفَل كذلك عمليات التكامل التي تسجّل الدخول بكلمة مرور. استخدم إصدارات التطبيقات التي تدعم الوضع المشترك. + + + Unit + + + IC (القيادة) + + + Dispatch + + + اختر مدة قفل عند الخمول من 1 إلى {0} دقيقة. + + + اختر طول مناوبة من 1 إلى {0} ساعة. + + + وضع الأجهزة المشتركة غير متاح في هذا النشر بعد، لذا لا يمكن فرضه على تطبيق آخر. + + + المصادقة متعددة العوامل لدى موفر الهوية + + + أخبر Resgrid بالطريقة التي يُبلغ بها موفر الهوية لديك أنه تحقق من العضو بالمصادقة متعددة العوامل. حيث تقبل هذه الإدارة المصادقة متعددة العوامل لدى موفر الهوية، يُحتسب تسجيل الدخول أو التحقق الذي يحمل إحدى هذه القيم مصادقةً متعددة العوامل، ولا يحتاج الأعضاء إلى أداة مصادقة من Resgrid لذلك. يجب أن يجتاز كل تغيير تسجيل دخول تجريبيًا قبل أن يسري. + + + أعدّ إعداد تسجيل دخول موحد وفعّله أولًا. + + + ساري: الإصدار {0}، تم اختباره في {1}. + + + غير ساري: لم يجتز الإصدار {0} اختباره بعد. + + + لا يوجد ربط محفوظ. + + + ما يطلبه Resgrid + + + ما يُحتسب مصادقة متعددة العوامل + + + قيمة واحدة في كل سطر. تتم مطابقة القيم تمامًا، بما في ذلك حالة الأحرف. يجب أن تحمل الاستجابة قيمة واحدة مدرجة على الأقل. + + + قيم acr_values المطلوبة (OIDC) + + + طلب claims (OIDC، بصيغة JSON) + + + مراجع فئات RequestedAuthnContext (SAML) + + + قيم amr (OIDC) + + + قيم acr (OIDC) + + + قيم acrs (OIDC، سياق المصادقة) + + + قيم AuthnContextClassRef (SAML) + + + حفظ الربط + + + إزالة الربط + + + تم حفظ الربط. يسري بعد اجتيازه تسجيل دخول تجريبيًا. + + + تمت إزالة الربط. لم تعد المصادقة متعددة العوامل لدى موفر الهوية تُحتسب في هذه الإدارة. + + + لا يمكن استخدام الربط: {0} + + + يتطلب تغيير الربط تحققًا حديثًا باستخدام تطبيق المصادقة أو مفتاح مرور. لا يمكن للمصادقة متعددة العوامل لدى موفر الهوية الموافقة على هذا التغيير. + + + ربط المصادقة متعددة العوامل لدى موفر الهوية + + + اختبار بتسجيل الدخول + + + يرسلك الاختبار الآن إلى موفر الهوية مع طلب المصادقة متعددة العوامل. عندما يُرجع قيمة يحتسبها الربط، يسري هذا الإصدار. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx index 9e94ff742..d4cc0ee7a 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Sicherheit und Berechtigungen - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Hier können Sie die Berechtigungen für Ihre Abteilung festlegen, zum Beispiel welche Benutzer oder Rollen Einsätze erstellen können oder wer Benutzer anlegen und entfernen darf. Änderungen an den Berechtigungen werden bei der nächsten Anmeldung an der Resgrid-Webanwendung wirksam. - Audit Logs + Audit-Protokolle + + + Nach Audit-Typ filtern + + + Alle Audit-Typen + + + Suchen Sie nach Benutzername, Benutzer- oder Audit-ID, E-Mail-Adresse, Datum/Uhrzeit oder Audit-Typ. Suche und Sortierung gelten innerhalb des ausgewählten Audit-Typs. + + + Audit-Protokolle durchsuchen: + + + Name, ID, E-Mail, Datum/Uhrzeit oder Typ + + + Zeitstempel + + + Typ + + + Protokolliert von + + + Ergebnis + + + Nachricht + + + Suchbegriffe + + + Aktionen + + + Erfolgreich + + + Fehlgeschlagen + + + Ansehen + + + _START_ bis _END_ von _TOTAL_ Einträgen + + + 0 bis 0 von 0 Einträgen + + + (gefiltert aus _MAX_ Einträgen insgesamt) + + + _MENU_ Einträge anzeigen + + + Wird geladen… + + + Keine Audit-Protokolleinträge vorhanden + + + Keine passenden Audit-Protokolleinträge gefunden + + + Erste + + + Letzte + + + Weiter + + + Zurück + + + : aktivieren, um die Spalte aufsteigend zu sortieren + + + : aktivieren, um die Spalte absteigend zu sortieren + + + System + + + Unbekannt + + + Audit-Protokoll anzeigen + + + Audit-Protokoll + + + Audit-Protokoll-ID: + + + Abteilungs-ID: + + + Audit-Typ: + + + Protokolltyp-ID: + + + Typbeschreibung: + + + Ergebnis: + + + Protokolliert von: + + + Benutzer-ID: + + + Protokolliert am (lokal): + + + Protokolliert am (UTC): + + + IP-Adresse: + + + Servername: + + + Objekt-ID: + + + Abteilungs-ID des Objekts: + + + User-Agent: + + + Nachricht: + + + Daten: + + + Nicht erfasst + + + Abteilungseinstellungen geändert + + + Benutzer hinzugefügt + + + Benutzer entfernt + + + Gruppe hinzugefügt + + + Gruppe entfernt + + + Gruppe geändert + + + Einheit hinzugefügt + + + Einheit entfernt + + + Einheit geändert + + + Profil aktualisiert + + + Berechtigungen geändert + + + Abonnement aktualisiert + + + Abonnement erstellt + + + Abonnement gekündigt + + + Abrechnungsdaten des Abonnements aktualisiert + + + Einsatz reaktiviert + + + Benutzerkonto gelöscht + + + Add-on-Abonnement angepasst + + + Löschung der Abteilung angefordert + + + Löschanfrage für Abteilung abgebrochen + + + Statische Schicht gelöscht + + + Statische Schicht aktualisiert + + + Benutzerdefinierter Status hinzugefügt + + + Benutzerdefinierter Status entfernt + + + Benutzerdefinierter Status aktualisiert + + + Detail des benutzerdefinierten Status aktualisiert + + + Einsatztyp hinzugefügt + + + Einsatztyp bearbeitet + + + Einsatztyp entfernt + + + Einsatzpriorität hinzugefügt + + + Einsatzpriorität bearbeitet + + + Einsatzpriorität entfernt + + + Einheitentyp hinzugefügt + + + Einheitentyp bearbeitet + + + Einheitentyp entfernt + + + Zertifizierungstyp hinzugefügt + + + Zertifizierungstyp bearbeitet + + + Zertifizierungstyp entfernt + + + Dokumentkategorie hinzugefügt + + + Dokumentkategorie bearbeitet + + + Dokumentkategorie entfernt + + + Dokument hinzugefügt + + + Dokument bearbeitet + + + Dokument entfernt + + + Notizkategorie hinzugefügt + + + Notizkategorie bearbeitet + + + Notizkategorie entfernt + + + Notiz hinzugefügt + + + Notiz bearbeitet + + + Notiz entfernt + + + Kontakt hinzugefügt + + + Kontakt bearbeitet + + + Kontakt entfernt + + + Kontaktkategorie hinzugefügt + + + Kontaktkategorie bearbeitet + + + Kontaktkategorie entfernt + + + Kontakt-Notiztyp hinzugefügt + + + Kontakt-Notiztyp bearbeitet + + + Kontakt-Notiztyp entfernt + + + Workflow erstellt + + + Workflow aktualisiert + + + Workflow gelöscht + + + Workflow-Schritt hinzugefügt + + + Workflow-Schritt aktualisiert + + + Workflow-Schritt gelöscht + + + Workflow-Zugangsdaten hinzugefügt + + + Workflow-Zugangsdaten aktualisiert + + + Workflow-Zugangsdaten gelöscht + + + Code zur Kontaktverifizierung gesendet + + + Kontaktverifizierung bestätigt + + + Kontaktverifizierung fehlgeschlagen + + + Zwei-Faktor-Authentifizierung aktiviert + + + Zwei-Faktor-Authentifizierung deaktiviert + + + Zwei-Faktor-Anmeldung verifiziert + + + Zwei-Faktor-Wiederherstellungscode verwendet + + + Zwei-Faktor-Sicherheitsprüfung verifiziert + + + SSO-Konfiguration erstellt + + + SSO-Konfiguration aktualisiert + + + SSO-Konfiguration gelöscht + + + SSO-Anmeldung erfolgreich + + + SSO-Anmeldung fehlgeschlagen + + + SSO-Benutzer bereitgestellt + + + SCIM-Benutzer erstellt + + + SCIM-Benutzer aktualisiert + + + SCIM-Benutzer deaktiviert + + + SCIM-Benutzer gelöscht + + + SCIM-Authentifizierung fehlgeschlagen + + + SCIM-Benutzer reaktiviert + + + SCIM-Gruppen aufgelistet + + + SCIM-Benutzer aufgelistet + + + SCIM-Benutzer abgerufen + + + SCIM-Bearer-Token bereitgestellt + + + SCIM-Bearer-Token rotiert + + + UDF-Definition erstellt + + + UDF-Definition aktualisiert + + + UDF-Definition gelöscht + + + UDF-Feld hinzugefügt + + + UDF-Feld aktualisiert + + + UDF-Feld entfernt + + + UDF-Feldwerte gespeichert + + + Route erstellt + + + Route aktualisiert + + + Route gelöscht + + + Route gestartet + + + Route abgeschlossen + + + Route abgebrochen + + + Route pausiert + + + Route fortgesetzt + + + Routenstopp eingecheckt + + + Routenstopp ausgecheckt + + + Routenstopp übersprungen + + + Routenabweichung erkannt + + + Routenabweichung quittiert + + + Check-In-Timer-Konfiguration erstellt + + + Check-In-Timer-Konfiguration aktualisiert + + + Check-In-Timer-Konfiguration gelöscht + + + Check-In-Timer-Überschreibung erstellt + + + Check-In-Timer-Überschreibung aktualisiert + + + Check-In-Timer-Überschreibung gelöscht + + + Check-In durchgeführt + + + Check-In-Timer für Einsatz aktiviert + + + Check-In-Timer für Einsatz deaktiviert + + + Einchecken bei Kalenderereignis + + + Auschecken bei Kalenderereignis + + + Kalender-Eincheckzeiten aktualisiert + + + Kalender-Eincheckdatensatz gelöscht + + + Kalender-Einchecken durch Administrator + + + Protokoll erstellt + + + Protokoll gelöscht + + + Kommunikationstest erstellt + + + Kommunikationstest aktualisiert + + + Kommunikationstest gelöscht + + + Kommunikationstest-Lauf gestartet + + + Wetterwarnungsquelle erstellt + + + Wetterwarnungsquelle aktualisiert + + + Wetterwarnungsquelle gelöscht + + + Wetterwarnungsquelle aktiviert + + + Wetterwarnungsquelle deaktiviert + + + Wetterwarnungszone erstellt + + + Wetterwarnungszone aktualisiert + + + Wetterwarnungszone gelöscht + + + Wetterwarnungszone aktiviert + + + Wetterwarnungszone deaktiviert + + + Wetterwarnungseinstellungen geändert + + + Funktionsschalter geändert + + + Funktionsschalter-Überschreibung geändert + + + Tracking-Gerät der Einheit erstellt + + + Tracking-Gerät der Einheit aktualisiert + + + Tracking-Gerät der Einheit deaktiviert + + + Tracking-Gerät der Einheit gelöscht + + + Tracking-Zugangsdaten der Einheit erstellt + + + Tracking-Zugangsdaten der Einheit rotiert + + + Tracking-Zugangsdaten der Einheit widerrufen + + + Löschung der Abteilung ausgeführt + + + Chat-Nachricht durch Moderation gelöscht + + + Chat-Benutzer stummgeschaltet + + + Stummschaltung des Chat-Benutzers aufgehoben + + + Chat-Benutzer gesperrt + + + Sperre des Chat-Benutzers aufgehoben + + + Chat-Kanal gesperrt + + + Chat-Kanal entsperrt + + + Chat-Kanal archiviert + + + Chat-Meldung geklärt + + + Chat-Einstellungen geändert + + + Chat-Export angefordert + + + Chat-Export heruntergeladen + + + Moderationsmeldung eingereicht + + + Moderationsvorgang erneut geöffnet + + + Moderationsvorgang abgeschlossen + + + Moderations-Beweismaterial heruntergeladen + + + Passwort durch Administrator zurückgesetzt + + + Anmeldesitzungen des Benutzers widerrufen + + + Ausnahmen der Datenschutz-Bestätigungsabfrage geändert + + + Kontakt-Einsatzplan hinzugefügt + + + Kontakt-Einsatzplan aktualisiert + + + Kontakt-Einsatzplan entfernt + + + Kontaktanhang hinzugefügt + + + Kontaktanhang entfernt + + + Checklistendefinition hinzugefügt + + + Checklistendefinition aktualisiert + + + Checklistendefinition veröffentlicht + + + Checklistendefinition stillgelegt + + + Checklistendefinition entfernt + + + Checklistendurchführung gestartet + + + Checklistenfortschritt gespeichert + + + Checklistendurchführung eingereicht + + + Zeugenbestätigung der Checkliste erteilt + + + Checklistendatei hinzugefügt + + + Checklistendatei entfernt + + + Checklisten-Zeitplan hinzugefügt + + + Checklisten-Zeitplan aktualisiert + + + Checklistentermin versäumt + + + Checklistentermin übersprungen + + + Checklisten-Erinnerungseinstellungen aktualisiert + + + Arbeitsauftrag geändert + + + Inventar geändert + + + Abrechnungsprofil geändert + + + Preisliste geändert + + + Rechnung erstellt + + + Rechnung aktualisiert + + + Rechnung gesendet + + + Rechnung storniert + + + Rechnungszahlung erfasst + + + Rechnungsidentität der Abteilung geändert + + + Zahlungsverbindung hergestellt + + + Zahlungsverbindung getrennt + + + Zahlungsverbindung widerrufen + + + Aktion für Zahlungsverbindung erforderlich + + + Zahlungsanfrage für Rechnung erstellt + + + Rechnungszahlung erstattet + + + Rechnungszahlung angefochten + + + Zahlungs-Webhook abgelehnt + + + Zahlungsanfrage für Rechnung fehlgeschlagen + + + Zahlungsanfrage für Rechnung abgelaufen + + + Zertifizierung hinzugefügt + + + Zertifizierung aktualisiert + + + Zertifizierung entfernt + + + Zertifizierungsstatus geändert + + + Zertifizierung verifiziert + + + Fortbildungspunkte hinzugefügt + + + Fortbildungspunkte entfernt + + + Zertifizierungsanforderung der Rolle geändert + + + Zertifizierungseinstellungen der Abteilung geändert + + + Rollenmitglied hinzugefügt + + + Rollenmitglied entfernt + + + Rollenmitglied wegen Zertifizierung entfernt + + + Einheitszertifizierung hinzugefügt + + + Einheitszertifizierung aktualisiert + + + Einheitszertifizierung entfernt + + + Status der Einheitszertifizierung geändert + + + Einsatz erstellt + + + Einsatz aktualisiert + + + Einsatzstatus geändert + + + Einsatzbesetzung geändert + + + Einsatzausrüstung geändert + + + Einsatzanhang hinzugefügt + + + Einsatzanhang entfernt + + + Zeitbericht erstellt + + + Zeitbericht aktualisiert + + + Zeitbericht eingereicht + + + Zeitbericht genehmigt + + + Zeitbericht storniert + + + Einsatzspesen hinzugefügt + + + Einsatzspesen aktualisiert + + + Einsatzspesen entfernt + + + Tarifplan erstellt + + + Tarifplan aktualisiert + + + Tarifplan gelöscht + + + Tarifplaneintrag geändert + + + Tarifzuschlag geändert + + + Vertrag erstellt + + + Vertrag aktualisiert + + + Vertragsstatus geändert + + + Vertrag gelöscht + + + Compliance-Dokument hinzugefügt + + + Compliance-Dokument aktualisiert + + + Compliance-Dokument entfernt + + + Angebot erstellt + + + Angebot aktualisiert + + + Angebot gesendet + + + Angebot angenommen + + + Angebot abgelehnt + + + Angebot zurückgezogen + + + Angebot abgelaufen + + + Angebot umgewandelt + + + Angebot gelöscht + + + Zeitbericht abgerechnet + + + Einsatzrechnung erzeugt + + + Cal OES MARS-Behördenprofil geändert + + + Cal OES MARS-Ressourcenprofil geändert + + + Cal OES MARS-Ratenprofil geändert + + + Cal OES MARS-Ratenentwurf zusammengestellt + + + Cal OES MARS-Rate geprüft + + + Cal OES MARS-Vereinbarung geändert + + + Cal OES MARS-Vereinbarung beobachtet + + + Cal OES MARS-Vorgang vorbereitet + + + Cal OES MARS-Vorgang validiert + + + Cal OES MARS-Erstattung berechnet + + + Cal OES MARS-Vorgang zur Übergabe geöffnet + + + Externer Cal OES MARS-Status beobachtet + + + Cal OES MARS-Rechnung genehmigt + + + Cal OES MARS-Rechnung abgelehnt + + + Cal OES MARS-Zahlung abgeglichen + + + Cal OES MARS-Vorgang gelöscht + + + Arbeitgeberprofil geändert + + + Betriebsstätte geändert + + + Beschäftigung geändert + + + Vergütung geändert + + + Jahreslohndaten importiert + + + Demografische Angaben für Lohndaten geändert + + + Lohndatenmeldung erstellt + + + Lohndatenmeldung validiert + + + Lohndatenmeldung eingefroren + + + Lohndatenmeldung exportiert + + + Lohndatenmeldung als bescheinigt markiert + + + Lohndatenmeldung korrigiert + + + Ressourcenkostenprofil geändert + + + Ressourcennutzung geändert + + + Einsatzkostenlauf erstellt + + + Einsatzkostenlauf eingefroren + + + Benutzer reaktiviert + + + Abteilungskonfiguration geändert + + + Admin-Assist-Prüfung geändert + + + Zugriff auf Admin-Assist-Diagnose + + + Einstellungen der KI-Alarmierung aktualisiert + + + Zugriff auf Admin-Assist-Änderungsplan + + + Sicherheitsrichtlinie geändert - Permission + Berechtigung Notiz - Value + Wert Rollen - Two-Factor Authentication (2FA) Enforcement + Pflicht zur Zwei-Faktor-Authentifizierung (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Schreiben Sie für Administratoren 2FA per Authenticator-App vor. Wenn dies aktiviert ist, werden Administratoren, die 2FA noch nicht eingerichtet haben, zur Einrichtung weitergeleitet, bevor sie auf Verwaltungsfunktionen zugreifen können. - Require 2FA for Admins + 2FA für Administratoren vorschreiben - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Legt fest, welche Benutzer mit Administratorrechten 2FA per Authenticator-App einrichten müssen (Google Authenticator, Microsoft Authenticator, Authy usw.). - Disabled (no requirement) + Deaktiviert (keine Pflicht) - Department Admins + Managing User + Abteilungsadministratoren + verwaltendes Mitglied - Department Admins + Managing User + Group Admins + Abteilungsadministratoren + verwaltendes Mitglied + Gruppenadministratoren - 2FA enforcement setting saved. + Einstellung zur 2FA-Pflicht gespeichert. - Failed to save 2FA enforcement setting. + Die Einstellung zur 2FA-Pflicht konnte nicht gespeichert werden. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Die 2FA-Pflicht kann nicht aktiviert werden: Das verwaltende Mitglied dieser Abteilung hat die Zwei-Faktor-Authentifizierung für sein Konto nicht aktiviert. Das verwaltende Mitglied muss 2FA aktivieren, bevor diese Einstellung eingeschaltet werden kann. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Die 2FA-Pflicht kann nicht aktiviert werden: Sie haben die Zwei-Faktor-Authentifizierung für Ihr eigenes Konto nicht aktiviert. Sie müssen 2FA aktivieren, bevor Sie sie für andere vorschreiben können. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Die 2FA-Pflicht kann nicht aktiviert werden: Weder Sie noch das verwaltende Mitglied haben die Zwei-Faktor-Authentifizierung aktiviert. Beide müssen 2FA aktivieren, bevor diese Einstellung eingeschaltet werden kann. - This setting is locked until the above conditions are met. + Diese Einstellung ist gesperrt, bis die oben genannten Bedingungen erfüllt sind. - Permission + Berechtigung Notiz - Selection + Auswahl - Group Only + Nur Gruppe Rollen - No Roles + Keine Rollen - N/A + k. A. + + + Alle + + + Abteilungsadministratoren + + + Abteilungs- und Gruppenadministratoren + + + Abteilungsadministratoren und ausgewählte Rollen + + + Abteilungs- und Gruppenadministratoren sowie ausgewählte Rollen - Security and Permissions + Sicherheit und Berechtigungen + + + Startseite - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Hier können Sie die Berechtigungen für Ihre Abteilung festlegen, zum Beispiel welche Benutzer oder Rollen Einsätze erstellen können oder wer Benutzer anlegen und entfernen darf. Änderungen an den Berechtigungen werden bei der nächsten Anmeldung an der Resgrid-Webanwendung wirksam. - Who can Add Users + Wer kann Benutzer hinzufügen - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Diese Option legt fest, wer der Abteilung Benutzer/Personal hinzufügen kann. Standardmäßig können nur Abteilungsadministratoren (und das verwaltende Mitglied) Benutzer hinzufügen. Es kann aber auch Gruppenadministratoren erlaubt werden, Benutzer hinzuzufügen (beschränkt auf die Gruppe, deren Administrator sie sind). - Who can Remove Users + Wer kann Benutzer entfernen - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Diese Option legt fest, wer Benutzer/Personal aus der Abteilung entfernen kann. Standardmäßig können nur Abteilungsadministratoren (und das verwaltende Mitglied) Benutzer entfernen. Es kann aber auch Gruppenadministratoren erlaubt werden, Benutzer zu entfernen (beschränkt auf die Benutzer der Gruppe, deren Administrator sie sind). - Who can Create Calls + Wer kann Einsätze erstellen - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Diese Option legt fest, wer im Resgrid-System manuell Einsätze erstellen kann. Standardmäßig können alle Einsätze erstellen. - Who can Delete Calls + Wer kann Einsätze löschen - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Diese Option legt fest, wer im Resgrid-System Einsätze löschen kann. Standardmäßig können alle Einsätze löschen. - Who can Close Calls + Wer kann Einsätze abschließen - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Diese Option legt fest, wer im Resgrid-System Einsätze abschließen kann. Standardmäßig können alle Einsätze abschließen. - Who can Add Data To Calls + Wer kann Einsätzen Daten hinzufügen - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Diese Option legt fest, wer im Resgrid-System Einsätzen Daten wie Bilder, Notizen und Dateien hinzufügen kann. Standardmäßig können alle Einsätzen Daten hinzufügen. - Who can Create Trainings + Wer kann Schulungen erstellen - This option determines who can create trainings. By default only Department Admins can create trainings. + Diese Option legt fest, wer Schulungen erstellen kann. Standardmäßig können nur Abteilungsadministratoren Schulungen erstellen. - Who can Add Documents + Wer kann Dokumente hinzufügen - This option determines who can add documents. By default Everyone can add documents. + Diese Option legt fest, wer Dokumente hinzufügen kann. Standardmäßig können alle Dokumente hinzufügen. - Who can Create Calendar Entries + Wer kann Kalendereinträge erstellen - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Diese Option legt fest, wer Kalendereinträge erstellen kann. Standardmäßig können alle Kalendereinträge erstellen. - Who can Create Notes + Wer kann Notizen erstellen - This option determines who can create notes. By default Everyone can create notes. + Diese Option legt fest, wer Notizen erstellen kann. Standardmäßig können alle Notizen erstellen. - Who can Add Log Entries + Wer kann Protokolleinträge hinzufügen - This option determines who can add log entries. By default Everyone can add log entries. + Diese Option legt fest, wer Protokolleinträge hinzufügen kann. Standardmäßig können alle Protokolleinträge hinzufügen. - Who can Create Shifts + Wer kann Schichten erstellen - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Diese Option legt fest, wer Schichten erstellen und bearbeiten kann. Standardmäßig können nur Abteilungsadministratoren Schichten erstellen und bearbeiten. - Who can View Personal Info + Wer kann persönliche Daten anzeigen - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Diese Option legt fest, wer personenbezogene Daten (PII) des Personals im System anzeigen kann, zum Beispiel E-Mail-Adresse, Telefonnummern usw. Standardmäßig können alle diese Informationen anzeigen. - Who can Adjust Inventory + Wer kann Inventar anpassen - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Diese Option legt fest, wer Inventarbestände im System anpassen kann. Standardmäßig können alle das Inventar anpassen. - Who can see the Location of Personnel + Wer kann den Standort des Personals sehen - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Diese Option legt fest, wer den Standort des Personals auf den Karten sehen kann. Um die Option auf Gruppenadministratoren und Rollen innerhalb einer Gruppe zu beschränken, müssen Sie die Option „Nur Gruppe“ aktivieren. - Who can see the Location of Units + Wer kann den Standort von Einheiten sehen - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Diese Option legt fest, wer den Standort von Einheiten auf den Karten sehen kann. Um die Option auf Gruppenadministratoren und Rollen innerhalb einer Gruppe zu beschränken, müssen Sie die Option „Nur Gruppe“ aktivieren. - Who can send messages + Wer kann Nachrichten senden - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Diese Option legt fest, wer Nachrichten (systeminterne Post) erstellen und senden kann. Standardmäßig können alle Nachrichten erstellen und senden. - Who can view users + Wer kann Benutzer anzeigen - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + Standardmäßig können alle Benutzer alle anderen Benutzer im System sehen. Mit dieser Option können Sie einschränken, wer Benutzer im System sehen kann. - Who can view units + Wer kann Einheiten anzeigen - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + Standardmäßig können alle Benutzer alle Einheiten im System sehen. Mit dieser Option können Sie einschränken, wer Einheiten im System anzeigen kann. - Who can view Contacts + Wer kann Kontakte anzeigen - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + Standardmäßig können alle Benutzer alle Kontakte im System sehen. Mit dieser Option können Sie einschränken, wer Kontakte im System anzeigen kann. Benutzer, die keine Kontakte anzeigen können, können diese auch keinem Einsatz hinzufügen. - Who can edit or create Contacts + Wer kann Kontakte bearbeiten oder erstellen - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + Standardmäßig können alle Benutzer Kontakte im System erstellen und bearbeiten. Mit dieser Option können Sie einschränken, wer Kontakte im System erstellen oder bearbeiten kann. - Who can delete Contacts + Wer kann Kontakte löschen - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + Standardmäßig können alle Benutzer Kontakte im System löschen. Mit dieser Option können Sie einschränken, wer Kontakte im System löschen kann. - Who can Create/Edit Workflows + Wer kann Workflows erstellen/bearbeiten - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Diese Option legt fest, wer Workflows und Workflow-Schritte erstellen, bearbeiten und löschen kann. Standardmäßig können nur Abteilungsadministratoren Workflows verwalten. - Who can Manage Workflow Credentials + Wer kann Workflow-Zugangsdaten verwalten - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Diese Option legt fest, wer verschlüsselte Zugangsdaten, die von Workflow-Aktionen verwendet werden (z. B. SMTP-Passwörter, API-Schlüssel), erstellen, bearbeiten und löschen kann. Standardmäßig können nur Abteilungsadministratoren Zugangsdaten verwalten. - Who can View Workflow Runs + Wer kann Workflow-Ausführungen anzeigen - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Diese Option legt fest, wer den Ausführungsverlauf von Workflows, Ausführungsprotokolle und Status-Dashboards anzeigen kann. Standardmäßig können nur Abteilungsadministratoren Workflow-Ausführungen anzeigen. - Single Sign-On (SSO) & SCIM + Single Sign-On (SSO) und SCIM - Single Sign-On & Identity Provisioning + Single Sign-On und Identitätsbereitstellung SSO / SCIM - Security Policy + Sicherheitsrichtlinie - Add OIDC Config + OIDC-Konfiguration hinzufügen - Add SAML 2.0 Config + SAML 2.0-Konfiguration hinzufügen - What is SSO & SCIM? + Was sind SSO und SCIM? Single Sign-On (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Ermöglichen Sie Mitgliedern der Abteilung, sich mit ihrer bestehenden Unternehmensidentität (Microsoft Entra ID, Okta, Google Workspace usw.) statt mit einem separaten Resgrid-Passwort anzumelden. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC – modernes Protokoll, ideal für Entra, Okta, Auth0, Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 – breit unterstützt von IdPs in Behörden und Unternehmen - SCIM 2.0 Provisioning + SCIM 2.0-Bereitstellung - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Synchronisieren Sie Benutzer automatisch aus Ihrem Identitätsanbieter. Wenn Sie Mitarbeitende in Ihrem Unternehmensverzeichnis aufnehmen oder austragen, übernimmt Resgrid diese Änderungen automatisch – ganz ohne manuelles Einladen oder Entfernen. - Auto-create new members when added in your IdP + Neue Mitglieder automatisch anlegen, wenn sie in Ihrem IdP hinzugefügt werden - Disable/remove members when deprovisioned + Mitglieder deaktivieren/entfernen, wenn ihre Bereitstellung aufgehoben wird - Keep names & email addresses in sync + Namen und E-Mail-Adressen synchron halten - Security Policy + Sicherheitsrichtlinie - Enforce department-wide compliance controls alongside SSO: + Setzen Sie ergänzend zu SSO abteilungsweite Compliance-Vorgaben durch: - Mandate MFA for all members + MFA für alle Mitglieder vorschreiben - Restrict login to SSO only (disable passwords) + Anmeldung nur über SSO zulassen (Passwörter deaktivieren) - Limit logins to specific IP CIDR ranges + Anmeldungen auf bestimmte IP-CIDR-Bereiche beschränken - Set password expiration & complexity rules + Regeln für Passwortablauf und -komplexität festlegen - Classify data level (Unclassified / CUI / Confidential) + Datenklassifizierungsstufe festlegen (Nicht klassifiziert / CUI / Vertraulich) - Mobile App Discovery URL + Discovery-URL für die mobile App - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + Die mobile Resgrid-App verwendet diese URL, um Ihre SSO-Einstellungen zu ermitteln, bevor der Anmeldebildschirm angezeigt wird. Geben Sie sie bei Bedarf an Ihr Team für mobile Geräte weiter. - Copy + Kopieren - Copied to clipboard! + In die Zwischenablage kopiert! - SSO Configurations + SSO-Konfigurationen - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Noch keine SSO-Konfigurationen vorhanden. Verwenden Sie die Schaltflächen oben, um OIDC oder SAML 2.0 hinzuzufügen. - Provider + Anbieter - Identifier / Endpoint + Bezeichner / Endpunkt Status - Local Login + Lokale Anmeldung - Auto-Provision + Automatische Bereitstellung SCIM - Created + Erstellt Aktionen - Enabled + Aktiviert - Disabled + Deaktiviert Ja - SSO Only + Nur SSO - On + Ein - Off + Aus Aktiv - No Token + Kein Token - Off + Aus Bearbeiten @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Löschen bestätigen - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Möchten Sie die SSO-Konfiguration {0} wirklich löschen? Dies kann nicht rückgängig gemacht werden. + + + Ungültiger Anbietertyp. + + + Für {0} ist bereits eine SSO-Konfiguration vorhanden. Verwenden Sie „Bearbeiten“, um sie zu ändern. + + + Die OIDC-Client-ID ist erforderlich. + + + Die OIDC-Autorität muss eine gültige HTTPS-URL sein. + + + Zum Validieren von SAML-Assertions ist ein Signaturzertifikat des IdP erforderlich. + + + Die Single-Sign-On-URL des Identitätsanbieters muss eine gültige HTTPS-URL sein. + + + SSO-Konfiguration für {0} erfolgreich erstellt. + + + SSO-Konfiguration erfolgreich aktualisiert. + + + SSO-Konfiguration für {0} gelöscht. Abbrechen @@ -453,10 +1569,10 @@ Löschen - New SSO Configuration + Neue SSO-Konfiguration - Edit SSO Configuration + SSO-Konfiguration bearbeiten Neu @@ -465,226 +1581,256 @@ Bearbeiten - Step 1 + Schritt 1 - Provider & Basic Settings + Anbieter und Grundeinstellungen - Identity Provider Protocol + Protokoll des Identitätsanbieters + + + OIDC (OpenID Connect) – Microsoft Entra, Okta, Google, Auth0 + + + SAML 2.0 – die meisten IdPs in Unternehmen und Behörden - Enable this SSO configuration + Diese SSO-Konfiguration aktivieren - Only one configuration per provider type is active at a time. + Pro Anbietertyp ist jeweils nur eine Konfiguration aktiv. - Allow local password login + Lokale Anmeldung mit Passwort zulassen - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + Wenn aktiviert, können sich Benutzer zusätzlich zu SSO weiterhin mit Benutzername und Passwort anmelden. Deaktivieren Sie diese Option zusammen mit der Einstellung „SSO erforderlich“ der Sicherheitsrichtlinie, um die Anmeldung ausschließlich über SSO zu erzwingen. - Auto-provision new users + Neue Benutzer automatisch bereitstellen - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Legt automatisch ein Resgrid-Konto an, wenn sich ein Benutzer zum ersten Mal über SSO authentifiziert und keine passende E-Mail-Adresse gefunden wird. Lassen Sie diese Option deaktiviert, wenn zuerst eine manuelle Einladung erforderlich sein soll. - Enable SCIM 2.0 provisioning + SCIM 2.0-Bereitstellung aktivieren - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Ermöglicht Ihrem IdP, Mitglieder automatisch anzulegen, zu aktualisieren und zu deaktivieren. Gehen Sie nach dem Speichern zur SCIM-Einrichtungsseite, um einen Bearer-Token zu generieren. - Default Rank for Auto-Provisioned Users + Standarddienstgrad für automatisch bereitgestellte Benutzer + + + (Kein Standarddienstgrad) - Optional. Applied only when Auto-Provision creates a new member. + Optional. Wird nur angewendet, wenn durch die automatische Bereitstellung ein neues Mitglied angelegt wird. - Step 2 — OIDC + Schritt 2 – OIDC - OpenID Connect Settings + Einstellungen für OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Wo Sie diese Werte finden: Registrieren Sie Resgrid in Ihrem IdP als öffentlichen Client (PKCE, für Mobilgeräte ist kein Client-Secret erforderlich) oder als Web-App (mit Secret für serverseitige Abläufe). Kopieren Sie die Client-ID und die Aussteller-URL aus der registrierten Anwendung. - Authority / Issuer URL + Autorität / Aussteller-URL https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Beispiele: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com - Client ID + Client-ID xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + Die öffentliche Client-Kennung, die Ihr IdP vergeben hat. Kann bedenkenlos angezeigt werden – sie ist kein Geheimnis. - Client Secret + Client-Secret - Secret stored + Secret gespeichert - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Wird mit Ihrem abteilungsspezifischen Schlüssel verschlüsselt gespeichert. Leer lassen, wenn Sie PKCE verwenden (Mobilgeräte/SPA – kein Secret erforderlich). - Leave blank to keep the existing secret unchanged. + Leer lassen, um das vorhandene Secret unverändert beizubehalten. - Step 2 — SAML 2.0 + Schritt 2 – SAML 2.0 - SAML 2.0 Settings + SAML 2.0-Einstellungen - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Wo Sie diese Werte finden: Erstellen Sie in Ihrem IdP eine neue SAML-Anwendung. Tragen Sie die unten angezeigte SP-Entitäts-ID und ACS-URL in Ihrem IdP ein und fügen Sie anschließend die Metadaten-URL oder das Zertifikat des IdP hier ein. - SP Entity ID (enter this into your IdP) + SP-Entitäts-ID (in Ihrem IdP eintragen) - ACS URL (Assertion Consumer Service — enter this into your IdP) + ACS-URL (Assertion Consumer Service – in Ihrem IdP eintragen) - This URL uses an encrypted department token — never exposes your plain department code. + Diese URL verwendet einen verschlüsselten Abteilungstoken – Ihr Abteilungscode wird nie im Klartext offengelegt. - SP Entity ID (editable) + SP-Entitäts-ID (bearbeitbar) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Muss exakt mit dem übereinstimmen, was Sie im IdP eingetragen haben. - IdP Metadata URL + Metadaten-URL des IdP https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid ruft diese URL regelmäßig ab, um die Zertifikate des IdP aktuell zu halten. - ACS URL (stored) + ACS-URL (gespeichert) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Wird automatisch aus der oben generierten URL übernommen. Nur ändern, wenn Sie in Ihrem IdP eine andere ACS-URL konfiguriert haben. + + + Umleitungs-URI für die in Resgrid gestartete Anmeldung (bei Ihrem Identitätsanbieter eintragen) + + + Fügen Sie ihn zusätzlich zu bereits registrierten Umleitungs-URIs hinzu; ältere Versionen der Resgrid-Apps verwenden weiterhin ihre eigenen. + + + Umleitungs-URIs für die Anmeldung in den Resgrid-Apps (jeweils bei Ihrem Identitätsanbieter eintragen) + + + Jede App und die Web-Edition jeder App kehren zu ihrer eigenen Adresse zurück; registrieren Sie daher alle. Ältere App-Versionen und Apps, deren Anmeldung nicht in Resgrid gestartet wird, verwenden diese. + + + {0} (Web) + + + Single-Sign-On-URL des Identitätsanbieters + + + Die SAML-Anmeldeadresse des Identitätsanbieters (HTTP-Redirect-Binding). Erforderlich für Anmeldungen, die in den Resgrid-Apps und auf der Website beginnen. - IdP Signing Certificate (PEM) + Signaturzertifikat des IdP (PEM) - Certificate stored + Zertifikat gespeichert - Encrypted at rest. Leave blank to keep the existing certificate. + Wird verschlüsselt gespeichert. Leer lassen, um das vorhandene Zertifikat beizubehalten. - SP Signing Key (PEM, optional) + SP-Signaturschlüssel (PEM, optional) - Key stored + Schlüssel gespeichert - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Wird verschlüsselt gespeichert. Leer lassen, wenn Ihr IdP keine signierten Anfragen verlangt. - Step 3 + Schritt 3 - Attribute Mapping + Attributzuordnung - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Ordnen Sie die Claim-Namen, die Ihr IdP sendet, den Resgrid-Benutzerfeldern zu. Leer lassen, um automatisch die Standard-Claim-URIs zu verwenden. Der Wert ist der Claim-Name, wie ihn Ihr IdP ausgibt. - Attribute Mapping JSON + Attributzuordnung (JSON) - Must be valid JSON. Use the quick-fill buttons on the right. + Muss gültiges JSON sein. Verwenden Sie die Schaltflächen zum Schnellausfüllen rechts. - Quick-fill presets + Vorlagen zum Schnellausfüllen - Click to auto-fill the mapping JSON with known defaults for your IdP. + Klicken Sie, um das Zuordnungs-JSON automatisch mit bekannten Standardwerten für Ihren IdP auszufüllen. - Supported Resgrid field keys + Unterstützte Resgrid-Feldschlüssel - Key + Schlüssel Beschreibung - User's email address + E-Mail-Adresse des Benutzers - Given / first name + Vorname - Surname / family name + Nachname / Familienname - Unique IdP subject (for account linking) + Eindeutiges IdP-Subject (für die Kontoverknüpfung) - Create Configuration + Konfiguration erstellen - Save Changes + Änderungen speichern Abbrechen - SCIM 2.0 Setup + SCIM 2.0-Einrichtung - SCIM 2.0 Provisioning Setup + Einrichtung der SCIM 2.0-Bereitstellung - SCIM Setup + SCIM-Einrichtung - Back to SSO + Zurück zu SSO - Important — Copy your SCIM Bearer Token Now + Wichtig – Kopieren Sie jetzt Ihren SCIM-Bearer-Token - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Dieser Token wird nur einmal angezeigt und kann nicht erneut abgerufen werden. Kopieren Sie ihn sofort und fügen Sie ihn in die SCIM-Konfiguration Ihres Identitätsanbieters ein. Wenn Sie ihn verlieren, klicken Sie auf „Token rotieren“, um einen neuen zu generieren. - Copy Token + Token kopieren - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Dieser Token wird mit Ihrem abteilungsspezifischen Verschlüsselungsschlüssel verschlüsselt in der Resgrid-Datenbank gespeichert. - SCIM Status + SCIM-Status - Provider + Anbieter - SCIM Enabled + SCIM aktiviert Ja @@ -693,61 +1839,61 @@ Nein - Bearer Token + Bearer-Token - Configured + Konfiguriert - Not Set + Nicht festgelegt - Rotate SCIM Token + SCIM-Token rotieren - Generate SCIM Token + SCIM-Token generieren - Rotating generates a new token and invalidates the old one. + Beim Rotieren wird ein neuer Token generiert und der alte ungültig. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Sind Sie sicher? Jede bestehende SCIM-Integration funktioniert nicht mehr, bis Sie den Token in Ihrem IdP aktualisieren. - SCIM Connector Settings + SCIM-Connector-Einstellungen - Enter these values into your identity provider's SCIM provisioning configuration. + Tragen Sie diese Werte in die SCIM-Bereitstellungskonfiguration Ihres Identitätsanbieters ein. - Setting + Einstellung - Value + Wert - SCIM Connector Base URL + Basis-URL des SCIM-Connectors - Authentication Method + Authentifizierungsmethode - Authorization Header + Autorisierungs-Header - Custom Header Name + Name des benutzerdefinierten Headers - Custom Header Value + Wert des benutzerdefinierten Headers - Supported Resources + Unterstützte Ressourcen - Supported Update Method + Unterstützte Aktualisierungsmethode - Step-by-Step Setup Guide + Schritt-für-Schritt-Anleitung zur Einrichtung Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Andere / Generisch - Any SCIM 2.0 compatible client can be configured with the following: + Jeder SCIM 2.0-kompatible Client kann wie folgt konfiguriert werden: - Resgrid SCIM field mapping + Resgrid-SCIM-Feldzuordnung Parameter - SCIM field + SCIM-Feld - Resgrid effect + Auswirkung in Resgrid @@ -814,112 +1960,136 @@ Für schrittweise IdP-spezifische Anleitungen (Okta, Microsoft Entra ID, Google Workspace) und Token-Verwaltung besuchen Sie die SCIM-Setup-Seite für jede Konfiguration. - Department Security Policy + Sicherheitsrichtlinie der Abteilung - Department Security Policy + Sicherheitsrichtlinie der Abteilung - Security Policy + Sicherheitsrichtlinie - Back to SSO + Zurück zu SSO - Security policy saved successfully. + Sicherheitsrichtlinie erfolgreich gespeichert. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Keine aktive SSO-Konfiguration. Sie können unten Passwortrichtlinien konfigurieren, aber „SSO erforderlich“ kann erst aktiviert werden, wenn Sie eine aktive SSO-Konfiguration haben. - Configure SSO → + SSO konfigurieren → - Authentication Controls + Authentifizierungseinstellungen - Require MFA for all members + MFA für alle Mitglieder vorschreiben - Members who have not enrolled in MFA will be prompted to do so on next login. + Mitglieder, die noch keine MFA eingerichtet haben, werden bei der nächsten Anmeldung dazu aufgefordert. - Require SSO — disable password login + SSO erforderlich – Passwortanmeldung deaktivieren - Requires active SSO config + Erfordert eine aktive SSO-Konfiguration - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Warnung: Wenn Sie dies aktivieren, werden alle Anmeldungen mit Benutzername und Passwort blockiert. Stellen Sie vor dem Aktivieren sicher, dass mindestens ein Administrator die SSO-Anmeldung erfolgreich getestet hat. - Session Timeout (minutes) + Sitzungs-Timeout (Minuten) - 0 = use system default. 480 = 8 hours. + 0 = Systemstandard verwenden. 480 = 8 Stunden. - Max Concurrent Sessions per User + Maximale gleichzeitige Sitzungen pro Benutzer - 0 = unlimited. Government environments typically set 1. + 0 = unbegrenzt. In Behördenumgebungen wird typischerweise 1 festgelegt. - Allowed IP Ranges (CIDR) + Zulässige IP-Bereiche (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Ein CIDR-Block pro Zeile oder durch Kommas getrennt. Leer = alle zulassen. Anmeldungen von außerhalb dieser Bereiche werden abgelehnt. - Data Classification Level + Datenklassifizierungsstufe + + + Nicht klassifiziert + + + CUI - Kontrollierte nicht klassifizierte Informationen + + + Vertraulich - Used for compliance reporting and audit logs. + Wird für Compliance-Berichte und Audit-Protokolle verwendet. - Password Policy + Passwortrichtlinie - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + Passwortrichtlinien gelten nur für lokale Anmeldungen (ohne SSO). Wenn Sie oben „SSO erforderlich“ aktivieren, haben diese Einstellungen keine Wirkung. - Password Expiration (days) + Passwortablauf (Tage) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = Passwörter laufen nie ab. 90 ist typisch für CUI-Umgebungen. - Minimum Password Length + Mindestlänge des Passworts - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Mindestens 8 (Systemstandard). Abteilungsrichtlinien können diesen Wert nur erhöhen. NIST empfiehlt 12 oder mehr, CUI erfordert 14 oder mehr. + + + Vom System erzwungene Passwortkomplexität + + + Alle Resgrid-Konten müssen den folgenden Standard für die Passwortkomplexität erfüllen. Er kann nicht durch eine Abteilungsrichtlinie deaktiviert werden. + + + Mindestens 8 Zeichen (oder die oben festgelegte Mindestlänge der Abteilung) + + + Mindestens eine Ziffer (Zahl) + + + Mindestens ein Großbuchstabe und ein Kleinbuchstabe - Require password complexity + Passwortkomplexität vorschreiben - Enforces at least one uppercase letter, one digit, and one special character. + Erzwingt mindestens einen Großbuchstaben, eine Ziffer und ein Sonderzeichen. - Quick presets + Schnellvorlagen - Government / CUI + Behörden / CUI - Standard Enterprise + Unternehmensstandard Minimal - Save Security Policy + Sicherheitsrichtlinie speichern Abbrechen - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Die reine SSO-Anmeldung kann nicht aktiviert werden: Es ist keine aktive SSO-Konfiguration vorhanden. Erstellen und aktivieren Sie zuerst eine SSO-Konfiguration. Das Passwort darf nicht leer sein. Das Passwort muss mindestens eine Ziffer (Zahl) enthalten. @@ -927,32 +2097,34 @@ Das Passwort muss mindestens einen Kleinbuchstaben enthalten. Das Passwort muss mindestens {0} Zeichen lang sein. Die Mindestlänge des Passworts darf nicht kleiner sein als der Systemstandard von 8 Zeichen. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. - Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Protokolleinträge löschen + Wer in Ihrer Abteilung Protokolleinträge löschen darf + Kalendersynchronisierung verwenden + Legt fest, wer Kalenderabonnement-URLs aktivieren und verwenden darf, um Resgrid-Kalenderereignisse mit externen Kalenderanwendungen zu synchronisieren. + Anmeldung Dispatch-App + Legt fest, wer sich an der Dispatch-App anmelden darf. Dispatch zeigt für jeden Einsatz die vertrauliche Kommunikation von Einsatzleitung, Einheiten und Einsatzkräften an; schränken Sie dies daher ein, wenn nicht alle Ihre Mitglieder Disponenten sind. + Anmeldung Command-App + Legt fest, wer als Einsatzleiter handeln darf: sich an der IC-App anmelden, bei einem Einsatz die Einsatzleitung übernehmen und Einsatzführungstafeln anzeigen. Wenn Sie diese Berechtigung enger als „Alle“ fassen, können die ausgewählten Personen außerdem an jeder Einsatzführungstafel mitarbeiten (Ressourcen zuweisen und verschieben, Timer und Kräfteübersicht führen), ohne dort eine ICS-Funktion innezuhaben – nützlich, um Disponenten in der Dispatch-App mithelfen zu lassen. Solange „Alle“ eingestellt ist, bleiben Aktionen auf der Tafel dem Einsatzleiter und den zugewiesenen ICS-Rollen vorbehalten. + Erweiterter Datenschutz + Diese Berechtigungen legen fest, wer mit verschlüsselten (geschützten) Daten arbeiten darf, wenn das Add-on „Erweiterter Datenschutz“ aktiv ist. Jedes Sichtbarmachen oder Bearbeiten erfordert zusätzlich eine aktuelle Zwei-Faktor-Bestätigung; diese Einstellungen bestimmen, wer es überhaupt versuchen darf. Anders als bei den meisten Resgrid-Berechtigungen gilt für nicht gesetzte Werte standardmäßig die angezeigte restriktive Auswahl. + Datenschutzeinstellungen verwalten + Wer Einstellungen des erweiterten Datenschutzes ändern darf, etwa das Bestätigungszeitfenster und die Optionen für Benachrichtigungsinhalte. Kauf, Anmeldung und Kündigung bleiben immer dem verwaltenden Mitglied der Abteilung vorbehalten. + Geschützte Einsatzdaten anzeigen + Wer geschützte Einsatzfelder (Einsatzart, Adresse, Kontaktdaten, Notizen) nach einer Zwei-Faktor-Bestätigung sichtbar machen darf. Standardwert ist „Alle“, da Einsatzkräfte eine Alarmierung lesen können müssen. + Geschützte Einsatzdaten bearbeiten + Wer geschützte Einsatzfelder nach einer Zwei-Faktor-Bestätigung bearbeiten darf. Standardwert ist „Alle“, passend zum normalen Einsatzablauf. + Geschützte Personaldaten anzeigen + Wer geschützte Personalinformationen (Personalnummern, Notfallkontakte) nach einer Zwei-Faktor-Bestätigung sichtbar machen darf. Standardwert: Abteilungsadministratoren. + Geschützte Kontaktdaten anzeigen + Wer geschützte Kontaktinformationen (Namen, Telefonnummern, amtliche Ausweisnummern, Standorte) nach einer Zwei-Faktor-Bestätigung sichtbar machen darf. Standardwert: Abteilungsadministratoren. + Geschützte operative Daten anzeigen + Wer geschützte operative Inhalte (Protokolle, Formulareinreichungen, Notizen und Anhänge der Einsatzleitung) nach einer Zwei-Faktor-Bestätigung sichtbar machen darf. Standardwert: Abteilungs- und Gruppenadministratoren. + Geschützte Daten exportieren + Wer Daten mit geschützten Feldern exportieren darf. Exporte verlassen den Schutz von Resgrid, daher wird jeder Export gesondert protokolliert. Standardwert: Abteilungsadministratoren; „Alle“ wird bewusst nicht angeboten. + Zustellung geschützter Daten konfigurieren + Wer ändern darf, wie geschützte Inhalte Resgrid per Push, SMS, E-Mail und Sprachanruf verlassen. Standardwert: Abteilungsadministratoren; „Alle“ wird bewusst nicht angeboten. + Notfallzugriff (Break-Glass) + Wer den protokollierten Notfallzugriff auf geschützte Daten nutzen darf. Er funktioniert nur, wenn der Notfallzugriff (Break-Glass) in der Schutzrichtlinie der Abteilung aktiviert ist, erfordert eine dokumentierte Begründung und benachrichtigt die Abteilung. Standardwert: Abteilungsadministratoren; „Alle“ wird bewusst nicht angeboten. Berichte Diese Berechtigungen steuern das Modul Berichte, den Nachfolger der Protokolle. Wie bei den meisten Resgrid-Berechtigungen gilt für eine nicht gesetzte Zeile der angezeigte Standardwert, der dem heutigen Verhalten der Protokolle entspricht. Beim Aktivieren von Berichten für Ihre Abteilung werden außerdem Ihre Einstellungen für „Protokoll erstellen“ und „Protokoll löschen“ auf die passenden Zeilen übernommen, sofern Sie sie hier nicht bereits gesetzt haben. Berichte sind für diese Abteilung noch nicht aktiviert. Sie können diese Einstellungen jetzt vorbereiten; sie gelten, sobald Berichte aktiviert werden. @@ -1012,4 +2184,190 @@ Legt fest, wer Inventar zwischen Lagerorten umlagern darf. Standardmäßig sind Abteilungsadministratoren berechtigt. + + Methoden für den zweiten Faktor + + + Legen Sie fest, welche Bestätigungsmethoden in dieser Abteilung als Multi-Faktor-Authentifizierung gelten. Codes aus einer Authenticator-App (TOTP) werden immer akzeptiert, daher sperrt das Deaktivieren einer Methode niemanden aus; Mitglieder, die sie verwendet haben, werden erneut zur Bestätigung aufgefordert. + + + Nur das verwaltende Mitglied der Abteilung kann diese Einstellungen ändern. + + + Passkeys sind auf diesem System noch nicht verfügbar. Diese Einstellungen gelten, sobald sie es sind. + + + Noch nicht verfügbar + + + Passkeys für Anmeldung und Sicherheitsprüfungen akzeptieren + + + Ein in derselben App registrierter Passkey gilt als MFA für die Anmeldung, den Wechsel der Abteilung und sensible Aktionen. + + + Passkeys für geschützte Daten akzeptieren + + + Ein Passkey gilt als MFA zum Anzeigen und Bearbeiten geschützter Daten. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten, sodass Mitglieder sich erneut bestätigen. + + + Bestätigung über die Responder-App akzeptieren + + + Wo Passkeys akzeptiert werden, kann ein Mitglied eine Anmeldung oder eine Anfrage für geschützte Daten mit dem Passkey in seiner Responder-App bestätigen. Nie für Sicherheitsänderungen verwendet. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. + + + MFA Ihres Identitätsanbieters für Anmeldung und Sicherheitsprüfungen akzeptieren + + + Erfordert eine getestete MFA-Zuordnung in Ihrer SSO-Konfiguration. + + + MFA Ihres Identitätsanbieters für geschützte Daten akzeptieren + + + Erfordert eine getestete MFA-Zuordnung in Ihrer SSO-Konfiguration. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. + + + Ihre SSO-Konfiguration hat keine getestete MFA-Zuordnung. Speichern Sie eine Zuordnung und schließen Sie ihren Test ab, bevor Sie die MFA Ihres Identitätsanbieters akzeptieren. + + + Um die MFA Ihres Identitätsanbieters zu akzeptieren, bestätigen Sie sich zuerst mit Ihrer Authenticator-App oder einem Passkey. Die MFA des Identitätsanbieters kann diese Änderung nicht genehmigen. + + + Eine aktuelle Bestätigung bei der Anmeldung für geschützte Daten gelten lassen + + + Mitglieder, die sich bei der Anmeldung mit MFA bestätigt haben, müssen sich innerhalb des Bestätigungszeitfensters nicht erneut bestätigen, um geschützte Daten anzuzeigen. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. + + + Das Entsperren eines gemeinsam genutzten Geräts für geschützte Daten gelten lassen + + + Auf gemeinsam genutzten Fahrzeugtablets und Arbeitsplätzen gilt die aktuelle Entsperr-Bestätigung des Bedieners zum Anzeigen geschützter Daten. Eine Änderung beendet den aktuellen Zugriff auf geschützte Daten. + + + Gemeinsam genutzte Fahrzeug- und Arbeitsplatzgeräte + + + Gemeinsam genutzte Fahrzeugtablets und Leitstellenarbeitsplätze sperren sich, wenn niemand sie nutzt, und enden mit der Schicht. Bediener entsperren mit ihrer eigenen Authenticator-App, einem Passkey oder einer Responder-Bestätigung, nie mit einem auf dem Gerät gespeicherten Passwort. Ein strengerer Wert gilt auch für bereits laufende Sitzungen. + + + Nur das verwaltende Mitglied der Abteilung kann die Richtlinie für gemeinsam genutzte Geräte ändern. + + + Der Modus für gemeinsam genutzte Geräte ist in dieser Installation noch nicht verfügbar. Diese Werte werden gespeichert, und er kann für keine weitere App vorgeschrieben werden, bis er es ist. + + + Nach so vielen Minuten Inaktivität sperren + + + 1 bis {0} Minuten. Nur die eigene Aktivität des Bedieners zählt; Hintergrundaktualisierungen und eingehende Alarme nicht. + + + Die Schicht nach so vielen Stunden beenden + + + 1 bis {0} Stunden nach der Anmeldung, unabhängig von der Aktivität. Die nächste Schicht meldet sich erneut an. + + + Gemeinsamen Modus immer verwenden für + + + Sitzungen dieser Apps in dieser Abteilung sperren sich bei Inaktivität immer und enden mit der Schicht, unabhängig von der Einstellung der Installation. Anmeldungen, die ihre App nicht angeben, zählen ebenfalls, daher sperren sich auch Integrationen, die sich mit einem Passwort anmelden. Verwenden Sie App-Versionen, die den gemeinsamen Modus unterstützen. + + + Unit (Einheit) + + + IC (Einsatzleitung) + + + Dispatch (Leitstelle) + + + Wählen Sie eine Sperre nach Inaktivität von 1 bis {0} Minuten. + + + Wählen Sie eine Schichtlänge von 1 bis {0} Stunden. + + + Der Modus für gemeinsam genutzte Geräte ist in dieser Installation noch nicht verfügbar und kann daher für keine weitere App vorgeschrieben werden. + + + MFA des Identitätsanbieters + + + Teilen Sie Resgrid mit, wie Ihr Identitätsanbieter meldet, dass er ein Mitglied mit MFA bestätigt hat. Wo diese Abteilung die MFA Ihres Identitätsanbieters akzeptiert, gilt eine Anmeldung oder Bestätigung mit einem dieser Werte als MFA, und Mitglieder benötigen dafür keinen Resgrid-Authenticator. Jede Änderung muss eine Testanmeldung bestehen, bevor sie wirksam wird. + + + Richten Sie zuerst eine SSO-Konfiguration ein und aktivieren Sie sie. + + + Wirksam: Version {0}, getestet am {1}. + + + Nicht wirksam: Version {0} hat ihren Test noch nicht bestanden. + + + Es ist keine Zuordnung gespeichert. + + + Was Resgrid anfordert + + + Was als MFA gilt + + + Ein Wert pro Zeile. Werte werden exakt verglichen, einschließlich Groß- und Kleinschreibung. Eine Antwort muss mindestens einen aufgeführten Wert enthalten. + + + Anzufordernde acr_values (OIDC) + + + claims-Anforderung (OIDC, JSON) + + + RequestedAuthnContext-Klassenreferenzen (SAML) + + + amr-Werte (OIDC) + + + acr-Werte (OIDC) + + + acrs-Werte (OIDC, Authentifizierungskontext) + + + AuthnContextClassRef-Werte (SAML) + + + Zuordnung speichern + + + Zuordnung entfernen + + + Zuordnung gespeichert. Sie wird wirksam, sobald sie eine Testanmeldung besteht. + + + Zuordnung entfernt. Die MFA Ihres Identitätsanbieters gilt in dieser Abteilung nicht mehr. + + + Die Zuordnung kann nicht verwendet werden: {0} + + + Zum Ändern der Zuordnung ist eine aktuelle Bestätigung mit Ihrer Authenticator-App oder einem Passkey nötig. Die MFA Ihres Identitätsanbieters kann diese Änderung nicht genehmigen. + + + MFA-Zuordnung des Identitätsanbieters + + + Mit einer Anmeldung testen + + + Der Test leitet Sie jetzt mit einer MFA-Anforderung zu Ihrem Identitätsanbieter. Wenn er einen Wert zurückgibt, den die Zuordnung zählt, wird diese Version wirksam. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx index 262a1be8d..3b9fdb95e 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx @@ -9,6 +9,363 @@ Ασφάλεια και Δικαιώματα Εδώ μπορείτε να ορίσετε τα δικαιώματα του τμήματός σας, για παράδειγμα ποιοι χρήστες ή ρόλοι μπορούν να δημιουργούν κλήσεις ή ποιος είναι εξουσιοδοτημένος να δημιουργεί και να αφαιρεί χρήστες. Οι αλλαγές στα δικαιώματα θα τεθούν σε ισχύ κατά την επόμενη σύνδεση στη διαδικτυακή εφαρμογή Resgrid. Αρχεία Ελέγχου + Φιλτράρισμα κατά τύπο ελέγχου + Όλοι οι τύποι ελέγχου + Αναζήτηση κατά όνομα χρήστη, αναγνωριστικό χρήστη ή ελέγχου, διεύθυνση email, ημερομηνία/ώρα ή τύπο ελέγχου. Η αναζήτηση και η ταξινόμηση εφαρμόζονται εντός του επιλεγμένου τύπου ελέγχου. + Αναζήτηση στα αρχεία ελέγχου: + Όνομα, αναγνωριστικό, email, ημερομηνία/ώρα ή τύπος + Χρονική Σήμανση + Τύπος + Καταγράφηκε Από + Αποτέλεσμα + Μήνυμα + Όροι Αναζήτησης + Ενέργειες + Επιτυχία + Αποτυχία + Προβολή + Εμφάνιση _START_ έως _END_ από _TOTAL_ καταχωρίσεις + Εμφάνιση 0 έως 0 από 0 καταχωρίσεις + (φιλτραρισμένες από _MAX_ συνολικά καταχωρίσεις) + Εμφάνιση _MENU_ καταχωρίσεων + Φόρτωση... + Δεν υπάρχουν καταχωρίσεις αρχείων ελέγχου + Δεν βρέθηκαν αντίστοιχες καταχωρίσεις αρχείων ελέγχου + Πρώτη + Τελευταία + Επόμενη + Προηγούμενη + : ενεργοποιήστε για αύξουσα ταξινόμηση της στήλης + : ενεργοποιήστε για φθίνουσα ταξινόμηση της στήλης + Σύστημα + Άγνωστη + Προβολή Καταχώρισης Αρχείου Ελέγχου + Καταχώριση Αρχείου Ελέγχου + Αναγνωριστικό Καταχώρισης: + Αναγνωριστικό Τμήματος: + Τύπος Ελέγχου: + Αναγνωριστικό Τύπου Καταγραφής: + Περιγραφή Τύπου: + Αποτέλεσμα: + Καταγράφηκε Από: + Αναγνωριστικό Χρήστη: + Καταγράφηκε Στις (Τοπική Ώρα): + Καταγράφηκε Στις (UTC): + Διεύθυνση IP: + Όνομα Διακομιστή: + Αναγνωριστικό Αντικειμένου: + Αναγνωριστικό Τμήματος Αντικειμένου: + User Agent: + Μήνυμα: + Δεδομένα: + Δεν καταγράφηκε + Άλλαξαν ρυθμίσεις τμήματος + Προστέθηκε χρήστης + Αφαιρέθηκε χρήστης + Προστέθηκε ομάδα + Αφαιρέθηκε ομάδα + Άλλαξε ομάδα + Προστέθηκε μονάδα + Αφαιρέθηκε μονάδα + Άλλαξε μονάδα + Ενημερώθηκε προφίλ + Άλλαξαν δικαιώματα + Ενημερώθηκε συνδρομή + Δημιουργήθηκε συνδρομή + Ακυρώθηκε συνδρομή + Ενημερώθηκαν στοιχεία χρέωσης συνδρομής + Επανενεργοποιήθηκε κλήση + Διαγράφηκε λογαριασμός χρήστη + Τροποποιήθηκε συνδρομή πρόσθετου + Ζητήθηκε διαγραφή τμήματος + Ακυρώθηκε αίτημα διαγραφής τμήματος + Διαγράφηκε στατική βάρδια + Ενημερώθηκε στατική βάρδια + Προστέθηκε προσαρμοσμένη κατάσταση + Αφαιρέθηκε προσαρμοσμένη κατάσταση + Ενημερώθηκε προσαρμοσμένη κατάσταση + Ενημερώθηκε λεπτομέρεια προσαρμοσμένης κατάστασης + Προστέθηκε τύπος κλήσης + Επεξεργάστηκε τύπος κλήσης + Αφαιρέθηκε τύπος κλήσης + Προστέθηκε προτεραιότητα κλήσης + Επεξεργάστηκε προτεραιότητα κλήσης + Αφαιρέθηκε προτεραιότητα κλήσης + Προστέθηκε τύπος μονάδας + Επεξεργάστηκε τύπος μονάδας + Αφαιρέθηκε τύπος μονάδας + Προστέθηκε τύπος πιστοποίησης + Επεξεργάστηκε τύπος πιστοποίησης + Αφαιρέθηκε τύπος πιστοποίησης + Προστέθηκε κατηγορία εγγράφων + Επεξεργάστηκε κατηγορία εγγράφων + Αφαιρέθηκε κατηγορία εγγράφων + Προστέθηκε έγγραφο + Επεξεργάστηκε έγγραφο + Αφαιρέθηκε έγγραφο + Προστέθηκε κατηγορία σημειώσεων + Επεξεργάστηκε κατηγορία σημειώσεων + Αφαιρέθηκε κατηγορία σημειώσεων + Προστέθηκε σημείωση + Επεξεργάστηκε σημείωση + Αφαιρέθηκε σημείωση + Προστέθηκε επαφή + Επεξεργάστηκε επαφή + Αφαιρέθηκε επαφή + Προστέθηκε κατηγορία επαφών + Επεξεργάστηκε κατηγορία επαφών + Αφαιρέθηκε κατηγορία επαφών + Προστέθηκε τύπος σημείωσης επαφής + Επεξεργάστηκε τύπος σημείωσης επαφής + Αφαιρέθηκε τύπος σημείωσης επαφής + Δημιουργήθηκε ροή εργασίας + Ενημερώθηκε ροή εργασίας + Διαγράφηκε ροή εργασίας + Προστέθηκε βήμα ροής εργασίας + Ενημερώθηκε βήμα ροής εργασίας + Διαγράφηκε βήμα ροής εργασίας + Προστέθηκε διαπιστευτήριο ροής εργασίας + Ενημερώθηκε διαπιστευτήριο ροής εργασίας + Διαγράφηκε διαπιστευτήριο ροής εργασίας + Απεστάλη κωδικός επαλήθευσης επικοινωνίας + Επιβεβαιώθηκε επαλήθευση επικοινωνίας + Απέτυχε επαλήθευση επικοινωνίας + Ενεργοποιήθηκε έλεγχος ταυτότητας δύο παραγόντων + Απενεργοποιήθηκε έλεγχος ταυτότητας δύο παραγόντων + Επαληθεύτηκε σύνδεση δύο παραγόντων + Χρησιμοποιήθηκε κωδικός ανάκτησης δύο παραγόντων + Επαληθεύτηκε ενισχυμένος έλεγχος δύο παραγόντων + Δημιουργήθηκε διαμόρφωση SSO + Ενημερώθηκε διαμόρφωση SSO + Διαγράφηκε διαμόρφωση SSO + Επιτεύχθηκε σύνδεση SSO + Απέτυχε σύνδεση SSO + Παρασχέθηκε χρήστης SSO + Δημιουργήθηκε χρήστης SCIM + Ενημερώθηκε χρήστης SCIM + Απενεργοποιήθηκε χρήστης SCIM + Διαγράφηκε χρήστης SCIM + Απέτυχε έλεγχος ταυτότητας SCIM + Επανενεργοποιήθηκε χρήστης SCIM + Ανακτήθηκε λίστα ομάδων SCIM + Ανακτήθηκε λίστα χρηστών SCIM + Ανακτήθηκε χρήστης SCIM + Παρασχέθηκε διακριτικό Bearer SCIM + Έγινε εναλλαγή διακριτικού Bearer SCIM + Δημιουργήθηκε ορισμός UDF + Ενημερώθηκε ορισμός UDF + Διαγράφηκε ορισμός UDF + Προστέθηκε πεδίο UDF + Ενημερώθηκε πεδίο UDF + Αφαιρέθηκε πεδίο UDF + Αποθηκεύτηκαν τιμές πεδίων UDF + Δημιουργήθηκε διαδρομή + Ενημερώθηκε διαδρομή + Διαγράφηκε διαδρομή + Ξεκίνησε διαδρομή + Ολοκληρώθηκε διαδρομή + Ακυρώθηκε διαδρομή + Τέθηκε σε παύση διαδρομή + Συνεχίστηκε διαδρομή + Δηλώθηκε άφιξη σε στάση διαδρομής + Δηλώθηκε αναχώρηση από στάση διαδρομής + Παραλείφθηκε στάση διαδρομής + Εντοπίστηκε απόκλιση διαδρομής + Αναγνωρίστηκε απόκλιση διαδρομής + Δημιουργήθηκε διαμόρφωση χρονομέτρου παρουσίας + Ενημερώθηκε διαμόρφωση χρονομέτρου παρουσίας + Διαγράφηκε διαμόρφωση χρονομέτρου παρουσίας + Δημιουργήθηκε παράκαμψη χρονομέτρου παρουσίας + Ενημερώθηκε παράκαμψη χρονομέτρου παρουσίας + Διαγράφηκε παράκαμψη χρονομέτρου παρουσίας + Πραγματοποιήθηκε δήλωση παρουσίας + Ενεργοποιήθηκε χρονόμετρο παρουσίας σε κλήση + Απενεργοποιήθηκε χρονόμετρο παρουσίας σε κλήση + Δηλώθηκε άφιξη σε συμβάν ημερολογίου + Δηλώθηκε αναχώρηση από συμβάν ημερολογίου + Ενημερώθηκαν ώρες άφιξης ημερολογίου + Διαγράφηκε δήλωση άφιξης ημερολογίου + Δηλώθηκε άφιξη ημερολογίου από διαχειριστή + Δημιουργήθηκε καταγραφή + Διαγράφηκε καταγραφή + Δημιουργήθηκε δοκιμή επικοινωνίας + Ενημερώθηκε δοκιμή επικοινωνίας + Διαγράφηκε δοκιμή επικοινωνίας + Ξεκίνησε εκτέλεση δοκιμής επικοινωνίας + Δημιουργήθηκε πηγή καιρικών ειδοποιήσεων + Ενημερώθηκε πηγή καιρικών ειδοποιήσεων + Διαγράφηκε πηγή καιρικών ειδοποιήσεων + Ενεργοποιήθηκε πηγή καιρικών ειδοποιήσεων + Απενεργοποιήθηκε πηγή καιρικών ειδοποιήσεων + Δημιουργήθηκε ζώνη καιρικών ειδοποιήσεων + Ενημερώθηκε ζώνη καιρικών ειδοποιήσεων + Διαγράφηκε ζώνη καιρικών ειδοποιήσεων + Ενεργοποιήθηκε ζώνη καιρικών ειδοποιήσεων + Απενεργοποιήθηκε ζώνη καιρικών ειδοποιήσεων + Άλλαξαν ρυθμίσεις καιρικών ειδοποιήσεων + Άλλαξε σημαία λειτουργίας + Άλλαξε παράκαμψη σημαίας λειτουργίας + Δημιουργήθηκε συσκευή εντοπισμού μονάδας + Ενημερώθηκε συσκευή εντοπισμού μονάδας + Απενεργοποιήθηκε συσκευή εντοπισμού μονάδας + Διαγράφηκε συσκευή εντοπισμού μονάδας + Δημιουργήθηκε διαπιστευτήριο εντοπισμού μονάδας + Έγινε εναλλαγή διαπιστευτηρίου εντοπισμού μονάδας + Ανακλήθηκε διαπιστευτήριο εντοπισμού μονάδας + Εκτελέστηκε διαγραφή τμήματος + Διαγράφηκε μήνυμα συνομιλίας από συντονιστή + Τέθηκε σε σίγαση χρήστης συνομιλίας + Άρθηκε σίγαση χρήστη συνομιλίας + Αποκλείστηκε χρήστης συνομιλίας + Άρθηκε αποκλεισμός χρήστη συνομιλίας + Κλειδώθηκε κανάλι συνομιλίας + Ξεκλειδώθηκε κανάλι συνομιλίας + Αρχειοθετήθηκε κανάλι συνομιλίας + Επιλύθηκε σήμανση συνομιλίας + Άλλαξαν ρυθμίσεις συνομιλίας + Ζητήθηκε εξαγωγή συνομιλίας + Λήφθηκε εξαγωγή συνομιλίας + Υποβλήθηκε αναφορά εποπτείας + Άνοιξε ξανά αίτημα εποπτείας + Ολοκληρώθηκε αίτημα εποπτείας + Λήφθηκαν αποδεικτικά στοιχεία εποπτείας + Επαναφέρθηκε κωδικός πρόσβασης από διαχειριστή + Ανακλήθηκαν συνεδρίες σύνδεσης χρήστη + Άλλαξαν εξαιρέσεις ενισχυμένης επαλήθευσης προστασίας δεδομένων + Προστέθηκε προσχέδιο επαφής + Ενημερώθηκε προσχέδιο επαφής + Αφαιρέθηκε προσχέδιο επαφής + Προστέθηκε συνημμένο επαφής + Αφαιρέθηκε συνημμένο επαφής + Προστέθηκε ορισμός λίστας ελέγχου + Ενημερώθηκε ορισμός λίστας ελέγχου + Δημοσιεύτηκε ορισμός λίστας ελέγχου + Αποσύρθηκε ορισμός λίστας ελέγχου + Αφαιρέθηκε ορισμός λίστας ελέγχου + Ξεκίνησε συμπλήρωση λίστας ελέγχου + Αποθηκεύτηκε πρόοδος λίστας ελέγχου + Υποβλήθηκε συμπλήρωση λίστας ελέγχου + Βεβαιώθηκε λίστα ελέγχου από μάρτυρα + Προστέθηκε αρχείο λίστας ελέγχου + Αφαιρέθηκε αρχείο λίστας ελέγχου + Προστέθηκε πρόγραμμα λίστας ελέγχου + Ενημερώθηκε πρόγραμμα λίστας ελέγχου + Δεν εκτελέστηκε προγραμματισμένη λίστα ελέγχου + Παραλείφθηκε προγραμματισμένη λίστα ελέγχου + Ενημερώθηκαν ρυθμίσεις υπενθυμίσεων λιστών ελέγχου + Άλλαξε εντολή εργασίας + Άλλαξε απόθεμα + Άλλαξε προφίλ χρέωσης + Άλλαξε τιμοκατάλογος τιμολόγησης + Δημιουργήθηκε τιμολόγιο + Ενημερώθηκε τιμολόγιο + Απεστάλη τιμολόγιο + Ακυρώθηκε τιμολόγιο + Καταχωρίστηκε πληρωμή τιμολογίου + Άλλαξε ταυτότητα χρέωσης τμήματος + Συνδέθηκε λογαριασμός πληρωμών + Αποσυνδέθηκε λογαριασμός πληρωμών + Ανακλήθηκε σύνδεση λογαριασμού πληρωμών + Απαιτείται ενέργεια για λογαριασμό πληρωμών + Δημιουργήθηκε αίτημα πληρωμής τιμολογίου + Επιστράφηκε πληρωμή τιμολογίου + Αμφισβητήθηκε πληρωμή τιμολογίου + Απορρίφθηκε webhook πληρωμής + Απέτυχε αίτημα πληρωμής τιμολογίου + Έληξε αίτημα πληρωμής τιμολογίου + Προστέθηκε πιστοποίηση + Ενημερώθηκε πιστοποίηση + Αφαιρέθηκε πιστοποίηση + Άλλαξε κατάσταση πιστοποίησης + Επαληθεύτηκε πιστοποίηση + Προστέθηκαν μονάδες πιστοποίησης + Αφαιρέθηκαν μονάδες πιστοποίησης + Άλλαξε απαίτηση πιστοποίησης ρόλου + Άλλαξαν ρυθμίσεις πιστοποιήσεων τμήματος + Προστέθηκε μέλος ρόλου + Αφαιρέθηκε μέλος ρόλου + Αφαιρέθηκε μέλος ρόλου λόγω πιστοποίησης + Προστέθηκε πιστοποίηση μονάδας + Ενημερώθηκε πιστοποίηση μονάδας + Αφαιρέθηκε πιστοποίηση μονάδας + Άλλαξε κατάσταση πιστοποίησης μονάδας + Δημιουργήθηκε αποστολή + Ενημερώθηκε αποστολή + Άλλαξε κατάσταση αποστολής + Άλλαξε σύνθεση αποστολής + Άλλαξε εξοπλισμός αποστολής + Προστέθηκε συνημμένο αποστολής + Αφαιρέθηκε συνημμένο αποστολής + Δημιουργήθηκε αναφορά χρόνου + Ενημερώθηκε αναφορά χρόνου + Υποβλήθηκε αναφορά χρόνου + Εγκρίθηκε αναφορά χρόνου + Ακυρώθηκε αναφορά χρόνου + Προστέθηκε έξοδο αποστολής + Ενημερώθηκε έξοδο αποστολής + Αφαιρέθηκε έξοδο αποστολής + Δημιουργήθηκε τιμοκατάλογος + Ενημερώθηκε τιμοκατάλογος + Διαγράφηκε τιμοκατάλογος + Άλλαξε καταχώριση τιμοκαταλόγου + Άλλαξε προσαύξηση τιμής + Δημιουργήθηκε σύμβαση υπηρεσιών + Ενημερώθηκε σύμβαση υπηρεσιών + Άλλαξε κατάσταση σύμβασης υπηρεσιών + Διαγράφηκε σύμβαση υπηρεσιών + Προστέθηκε έγγραφο συμμόρφωσης + Ενημερώθηκε έγγραφο συμμόρφωσης + Αφαιρέθηκε έγγραφο συμμόρφωσης + Δημιουργήθηκε προσφορά + Ενημερώθηκε προσφορά + Απεστάλη προσφορά + Έγινε αποδεκτή προσφορά + Απορρίφθηκε προσφορά + Αποσύρθηκε προσφορά + Έληξε προσφορά + Μετατράπηκε προσφορά + Διαγράφηκε προσφορά + Τιμολογήθηκε αναφορά χρόνου + Εκδόθηκε τιμολόγιο αποστολής + Άλλαξε προφίλ υπηρεσίας Cal OES MARS + Άλλαξε προφίλ πόρου Cal OES MARS + Άλλαξε προφίλ τιμών Cal OES MARS + Συντάχθηκε πρόχειρο τιμών Cal OES MARS + Ελέγχθηκε τιμή Cal OES MARS + Άλλαξε συμφωνία Cal OES MARS + Παρατηρήθηκε συμφωνία Cal OES MARS + Προετοιμάστηκε στοιχείο εργασίας Cal OES MARS + Επικυρώθηκε στοιχείο εργασίας Cal OES MARS + Υπολογίστηκε αποζημίωση Cal OES MARS + Ανοίχτηκε στοιχείο εργασίας Cal OES MARS για παράδοση + Παρατηρήθηκε εξωτερική κατάσταση Cal OES MARS + Εγκρίθηκε τιμολόγιο Cal OES MARS + Απορρίφθηκε τιμολόγιο Cal OES MARS + Συμφωνήθηκε πληρωμή Cal OES MARS + Διαγράφηκε στοιχείο εργασίας Cal OES MARS + Άλλαξε προφίλ εργοδότη εργατικού δυναμικού + Άλλαξε εγκατάσταση εργατικού δυναμικού + Άλλαξε απασχόληση εργατικού δυναμικού + Άλλαξαν αμοιβές εργατικού δυναμικού + Εισήχθησαν ετήσια στοιχεία αμοιβών εργατικού δυναμικού + Άλλαξαν δημογραφικά στοιχεία δεδομένων αμοιβών + Δημιουργήθηκε αναφορά δεδομένων αμοιβών + Επικυρώθηκε αναφορά δεδομένων αμοιβών + Παγώθηκε αναφορά δεδομένων αμοιβών + Εξήχθη αναφορά δεδομένων αμοιβών + Σημειώθηκε πιστοποίηση αναφοράς δεδομένων αμοιβών + Διορθώθηκε αναφορά δεδομένων αμοιβών + Άλλαξε προφίλ κόστους πόρων + Άλλαξε χρήση πόρων + Δημιουργήθηκε υπολογισμός κόστους πεδίου + Παγώθηκε υπολογισμός κόστους πεδίου + Επανενεργοποιήθηκε χρήστης + Άλλαξε διαμόρφωση τμήματος + Άλλαξε έλεγχος Admin Assist + Έγινε πρόσβαση σε διαγνωστικό Admin Assist + Ενημερώθηκαν ρυθμίσεις διάθεσης με τεχνητή νοημοσύνη + Έγινε πρόσβαση σε σχέδιο αλλαγής Admin Assist + Άλλαξε πολιτική ασφαλείας Δικαίωμα Σημείωση Τιμή @@ -37,9 +394,15 @@ Ρόλοι Χωρίς Ρόλους Μ/Δ + Όλοι + Διαχειριστές Τμήματος + Διαχειριστές Τμήματος και Ομάδων + Διαχειριστές Τμήματος και επιλεγμένοι ρόλοι + Διαχειριστές Τμήματος και Ομάδων, καθώς και επιλεγμένοι ρόλοι Ασφάλεια και Δικαιώματα + Αρχική Εδώ μπορείτε να ορίσετε τα δικαιώματα του τμήματός σας, για παράδειγμα ποιοι χρήστες ή ρόλοι μπορούν να δημιουργούν κλήσεις ή ποιος είναι εξουσιοδοτημένος να δημιουργεί και να αφαιρεί χρήστες. Οι αλλαγές στα δικαιώματα θα τεθούν σε ισχύ κατά την επόμενη σύνδεση στη διαδικτυακή εφαρμογή Resgrid. @@ -169,6 +532,15 @@ SCIM Επιβεβαίωση Διαγραφής Είστε βέβαιοι ότι θέλετε να διαγράψετε τη διαμόρφωση SSO {0}; Αυτό δεν μπορεί να αναιρεθεί. + Μη έγκυρος τύπος παρόχου. + Υπάρχει ήδη διαμόρφωση SSO για {0}. Χρησιμοποιήστε την «Επεξεργασία» για να την τροποποιήσετε. + Το Client ID του OIDC είναι υποχρεωτικό. + Η αρχή OIDC πρέπει να είναι έγκυρη διεύθυνση URL HTTPS. + Απαιτείται πιστοποιητικό υπογραφής του παρόχου ταυτότητας για την επικύρωση των διαβεβαιώσεων SAML. + Η διεύθυνση URL ενιαίας σύνδεσης του παρόχου ταυτότητας πρέπει να είναι έγκυρη διεύθυνση URL HTTPS. + Η διαμόρφωση SSO {0} δημιουργήθηκε με επιτυχία. + Η διαμόρφωση SSO ενημερώθηκε με επιτυχία. + Η διαμόρφωση SSO {0} διαγράφηκε. Ακύρωση Διαγραφή @@ -180,6 +552,8 @@ Βήμα 1 Πάροχος και Βασικές Ρυθμίσεις Πρωτόκολλο Παρόχου Ταυτότητας + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + SAML 2.0 — Οι περισσότεροι εταιρικοί και κυβερνητικοί πάροχοι ταυτότητας Ενεργοποίηση αυτής της διαμόρφωσης SSO Μόνο μία διαμόρφωση ανά τύπο παρόχου είναι ενεργή κάθε φορά. Να επιτρέπεται τοπική σύνδεση με κωδικό πρόσβασης @@ -189,6 +563,7 @@ Ενεργοποίηση παροχής SCIM 2.0 Επιτρέπει στον πάροχο ταυτότητάς σας να δημιουργεί/ενημερώνει/απενεργοποιεί αυτόματα μέλη. Μετά την αποθήκευση, μεταβείτε στη σελίδα Ρύθμισης SCIM για να δημιουργήσετε διακριτικό bearer. Προεπιλεγμένος Βαθμός για Χρήστες Αυτόματης Παροχής + (Χωρίς προεπιλεγμένο βαθμό) Προαιρετικό. Εφαρμόζεται μόνο όταν η Αυτόματη Παροχή δημιουργεί νέο μέλος. Βήμα 2 — OIDC Ρυθμίσεις OpenID Connect @@ -217,6 +592,13 @@ Το Resgrid θα ανακτά περιοδικά αυτή τη διεύθυνση URL ώστε να παραμένει ενημερωμένο με τα πιστοποιητικά του παρόχου ταυτότητας. Διεύθυνση URL ACS (αποθηκευμένη) Συμπληρώνεται αυτόματα από την παραπάνω παραγόμενη διεύθυνση URL. Τροποποιήστε την μόνο αν διαμορφώσατε διαφορετική διεύθυνση URL ACS στον πάροχο ταυτότητάς σας. + URI ανακατεύθυνσης για σύνδεση που ξεκινά από το Resgrid (καταχωρίστε το στον πάροχο ταυτότητας) + Προσθέστε το δίπλα σε όποιο URI ανακατεύθυνσης έχετε ήδη καταχωρίσει· οι παλαιότερες εκδόσεις των εφαρμογών Resgrid συνεχίζουν να χρησιμοποιούν τα δικά τους. + URI ανακατεύθυνσης για σύνδεση στις εφαρμογές Resgrid (καταχωρίστε το καθένα στον πάροχο ταυτότητας) + Κάθε εφαρμογή, και η έκδοση web κάθε εφαρμογής, επιστρέφει στη δική της διεύθυνση, επομένως καταχωρίστε τα όλα. Τα χρησιμοποιούν οι παλαιότερες εκδόσεις των εφαρμογών και οι εφαρμογές των οποίων η σύνδεση δεν ξεκινά από το Resgrid. + {0} (web) + URL ενιαίας σύνδεσης του παρόχου ταυτότητας + Η διεύθυνση σύνδεσης SAML του παρόχου ταυτότητας (σύνδεση HTTP-Redirect). Απαιτείται για συνδέσεις που ξεκινούν από τις εφαρμογές και τον ιστότοπο του Resgrid. Πιστοποιητικό Υπογραφής Παρόχου Ταυτότητας (PEM) Το πιστοποιητικό αποθηκεύτηκε Κρυπτογραφημένο κατά την αποθήκευση. Αφήστε το κενό για να διατηρήσετε το υπάρχον πιστοποιητικό. @@ -341,6 +723,9 @@ Επιτρεπόμενα Εύρη IP (CIDR) Ένα μπλοκ CIDR ανά γραμμή ή χωρισμένα με κόμμα. Κενό = να επιτρέπονται όλα. Οι συνδέσεις εκτός αυτών των ευρών θα απορρίπτονται. Επίπεδο Ταξινόμησης Δεδομένων + Αδιαβάθμητα + CUI - Ελεγχόμενες αδιαβάθμητες πληροφορίες + Εμπιστευτικά Χρησιμοποιείται για αναφορές συμμόρφωσης και αρχεία ελέγχου. Πολιτική Κωδικών Πρόσβασης Οι πολιτικές κωδικών πρόσβασης ισχύουν μόνο για τοπικές συνδέσεις (εκτός SSO). Αν ενεργοποιήσετε την Απαίτηση SSO παραπάνω, αυτές οι ρυθμίσεις δεν έχουν αποτέλεσμα. @@ -457,6 +842,192 @@ Καθορίζει ποιος μπορεί να μεταφέρει αποθέματα μεταξύ τοποθεσιών. Από προεπιλογή, επιτρέπεται στους διαχειριστές του τμήματος. + + Μέθοδοι δεύτερου παράγοντα + + + Επιλέξτε ποιες μέθοδοι επαλήθευσης μετρούν ως έλεγχος ταυτότητας πολλών παραγόντων σε αυτό το τμήμα. Οι κωδικοί εφαρμογής ελέγχου ταυτότητας (TOTP) γίνονται πάντα δεκτοί, οπότε η απενεργοποίηση μιας μεθόδου δεν αποκλείει κανέναν· τα μέλη που τη χρησιμοποίησαν καλούνται να επαληθεύσουν ξανά. + + + Μόνο το διαχειριστικό μέλος του τμήματος μπορεί να αλλάξει αυτές τις ρυθμίσεις. + + + Τα κλειδιά πρόσβασης δεν είναι ακόμη διαθέσιμα σε αυτό το σύστημα. Αυτές οι επιλογές θα ισχύσουν όταν γίνουν. + + + Δεν είναι ακόμη διαθέσιμο + + + Αποδοχή κλειδιών πρόσβασης για σύνδεση και ελέγχους ασφαλείας + + + Ένα κλειδί πρόσβασης καταχωρισμένο στην ίδια εφαρμογή μετρά ως MFA για σύνδεση, αλλαγή τμήματος και ευαίσθητες ενέργειες. + + + Αποδοχή κλειδιών πρόσβασης για προστατευμένα δεδομένα + + + Ένα κλειδί πρόσβασης μετρά ως MFA για την εμφάνιση και την επεξεργασία προστατευμένων δεδομένων. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα, οπότε τα μέλη επαληθεύουν ξανά. + + + Αποδοχή έγκρισης από την εφαρμογή Responder + + + Όπου γίνονται δεκτά κλειδιά πρόσβασης, ένα μέλος μπορεί να εγκρίνει μια σύνδεση ή ένα αίτημα για προστατευμένα δεδομένα με το κλειδί πρόσβασης στην εφαρμογή Responder του. Δεν χρησιμοποιείται ποτέ για αλλαγές ασφαλείας. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. + + + Αποδοχή του MFA του παρόχου ταυτότητάς σας για σύνδεση και ελέγχους ασφαλείας + + + Απαιτεί δοκιμασμένη αντιστοίχιση MFA στη ρύθμιση SSO σας. + + + Αποδοχή του MFA του παρόχου ταυτότητάς σας για προστατευμένα δεδομένα + + + Απαιτεί δοκιμασμένη αντιστοίχιση MFA στη ρύθμιση SSO σας. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. + + + Η ρύθμιση SSO σας δεν έχει δοκιμασμένη αντιστοίχιση MFA. Αποθηκεύστε μια αντιστοίχιση και ολοκληρώστε τη δοκιμή της πριν αποδεχτείτε το MFA του παρόχου ταυτότητάς σας. + + + Για να αποδεχτείτε το MFA του παρόχου ταυτότητάς σας, επαληθεύστε πρώτα με την εφαρμογή ελέγχου ταυτότητας ή ένα κλειδί πρόσβασης. Το MFA του παρόχου ταυτότητας δεν μπορεί να εγκρίνει αυτή την αλλαγή. + + + Να ανοίγει τα προστατευμένα δεδομένα μια πρόσφατη επαλήθευση κατά τη σύνδεση + + + Τα μέλη που επαλήθευσαν MFA κατά τη σύνδεση δεν χρειάζεται να επαληθεύσουν ξανά για να εμφανίσουν προστατευμένα δεδομένα μέσα στο παράθυρο επαλήθευσης. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. + + + Να ανοίγει τα προστατευμένα δεδομένα το ξεκλείδωμα κοινόχρηστης συσκευής + + + Σε κοινόχρηστα tablet οχημάτων και σταθμούς εργασίας, η πρόσφατη επαλήθευση ξεκλειδώματος του χειριστή μετρά για την εμφάνιση προστατευμένων δεδομένων. Η αλλαγή τερματίζει την τρέχουσα πρόσβαση σε προστατευμένα δεδομένα. + + + Κοινόχρηστες συσκευές οχημάτων και σταθμών εργασίας + + + Τα κοινόχρηστα tablet οχημάτων και οι σταθμοί εργασίας κέντρου αποστολής κλειδώνουν όταν δεν τα χρησιμοποιεί κανείς και τερματίζονται με τη βάρδια. Οι χειριστές ξεκλειδώνουν με τη δική τους εφαρμογή ελέγχου ταυτότητας, κλειδί πρόσβασης ή έγκριση Responder, ποτέ με κωδικό πρόσβασης αποθηκευμένο στη συσκευή. Μια αυστηρότερη τιμή εδώ ισχύει και για συνεδρίες που βρίσκονται ήδη σε εξέλιξη. + + + Μόνο το διαχειριστικό μέλος του τμήματος μπορεί να αλλάξει την πολιτική κοινόχρηστων συσκευών. + + + Η λειτουργία κοινόχρηστων συσκευών δεν είναι ακόμη διαθέσιμη σε αυτή την εγκατάσταση. Οι τιμές διατηρούνται και δεν μπορεί να απαιτηθεί για άλλη εφαρμογή μέχρι να γίνει διαθέσιμη. + + + Κλείδωμα μετά από τόσα λεπτά αδράνειας + + + 1 έως {0} λεπτά. Μετρά μόνο η δραστηριότητα του ίδιου του χειριστή· οι ενημερώσεις στο παρασκήνιο και οι εισερχόμενες ειδοποιήσεις όχι. + + + Λήξη της βάρδιας μετά από τόσες ώρες + + + 1 έως {0} ώρες μετά τη σύνδεση, ανεξάρτητα από τη δραστηριότητα. Η επόμενη βάρδια συνδέεται ξανά. + + + Να χρησιμοποιείται πάντα η κοινόχρηστη λειτουργία για + + + Οι συνεδρίες αυτών των εφαρμογών σε αυτό το τμήμα κλειδώνουν πάντα σε αδράνεια και τερματίζονται με τη βάρδια, ανεξάρτητα από τη ρύθμιση της εγκατάστασης. Μετρούν και οι συνδέσεις που δεν δηλώνουν εφαρμογή, οπότε κλειδώνουν και οι ενσωματώσεις που συνδέονται με κωδικό πρόσβασης. Χρησιμοποιήστε εκδόσεις εφαρμογών που υποστηρίζουν την κοινόχρηστη λειτουργία. + + + Unit + + + IC (Διοίκηση) + + + Dispatch + + + Επιλέξτε κλείδωμα αδράνειας από 1 έως {0} λεπτά. + + + Επιλέξτε διάρκεια βάρδιας από 1 έως {0} ώρες. + + + Η λειτουργία κοινόχρηστων συσκευών δεν είναι ακόμη διαθέσιμη σε αυτή την εγκατάσταση, οπότε δεν μπορεί να απαιτηθεί για άλλη εφαρμογή. + + + MFA του παρόχου ταυτότητας + + + Ενημερώστε το Resgrid πώς ο πάροχος ταυτότητάς σας δηλώνει ότι επαλήθευσε ένα μέλος με MFA. Όπου αυτό το τμήμα αποδέχεται το MFA του παρόχου ταυτότητάς σας, μια σύνδεση ή επαλήθευση που φέρει μία από αυτές τις τιμές μετρά ως MFA και τα μέλη δεν χρειάζονται εφαρμογή ελέγχου ταυτότητας του Resgrid. Κάθε αλλαγή πρέπει να περάσει μια δοκιμαστική σύνδεση πριν ισχύσει. + + + Ρυθμίστε και ενεργοποιήστε πρώτα μια ρύθμιση SSO. + + + Σε ισχύ: έκδοση {0}, δοκιμάστηκε {1}. + + + Εκτός ισχύος: η έκδοση {0} δεν έχει περάσει ακόμη τη δοκιμή της. + + + Δεν υπάρχει αποθηκευμένη αντιστοίχιση. + + + Τι ζητά το Resgrid + + + Τι μετρά ως MFA + + + Μία τιμή ανά γραμμή. Οι τιμές συγκρίνονται ακριβώς, συμπεριλαμβανομένων πεζών και κεφαλαίων. Μια απάντηση χρειάζεται τουλάχιστον μία από τις τιμές. + + + acr_values προς αίτηση (OIDC) + + + Αίτημα claims (OIDC, JSON) + + + Αναφορές κλάσεων RequestedAuthnContext (SAML) + + + Τιμές amr (OIDC) + + + Τιμές acr (OIDC) + + + Τιμές acrs (OIDC, πλαίσιο ελέγχου ταυτότητας) + + + Τιμές AuthnContextClassRef (SAML) + + + Αποθήκευση αντιστοίχισης + + + Κατάργηση αντιστοίχισης + + + Η αντιστοίχιση αποθηκεύτηκε. Θα ισχύσει μόλις περάσει μια δοκιμαστική σύνδεση. + + + Η αντιστοίχιση καταργήθηκε. Το MFA του παρόχου ταυτότητας δεν μετρά πλέον σε αυτό το τμήμα. + + + Η αντιστοίχιση δεν μπορεί να χρησιμοποιηθεί: {0} + + + Η αλλαγή της αντιστοίχισης απαιτεί πρόσφατη επαλήθευση με την εφαρμογή ελέγχου ταυτότητας ή ένα κλειδί πρόσβασης. Το MFA του παρόχου ταυτότητας δεν μπορεί να εγκρίνει αυτή την αλλαγή. + + + Αντιστοίχιση MFA του παρόχου ταυτότητας + + + Δοκιμή με σύνδεση + + + Η δοκιμή σας στέλνει τώρα στον πάροχο ταυτότητας ζητώντας MFA. Όταν επιστρέψει τιμή που μετρά η αντιστοίχιση, αυτή η έκδοση τίθεται σε ισχύ. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx index ad69dafd4..07e9b0378 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx @@ -9,6 +9,363 @@ Security and Permissions Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. Audit Logs + Filter by audit type + All audit types + Search by user name, user or audit ID, email address, date/time, or audit type. Search and sorting apply within the selected audit type. + Search audit logs: + Name, ID, email, date/time, or type + Timestamp + Type + Logged By + Result + Message + Search Terms + Actions + Successful + Failed + View + Showing _START_ to _END_ of _TOTAL_ entries + Showing 0 to 0 of 0 entries + (filtered from _MAX_ total entries) + Show _MENU_ entries + Loading... + No audit log entries + No matching audit log entries found + First + Last + Next + Previous + : activate to sort column ascending + : activate to sort column descending + System + Unknown + View Audit Log + Audit Log + Audit Log ID: + Department ID: + Audit Type: + Log Type ID: + Type Description: + Result: + Logged By: + User ID: + Logged On (Local): + Logged On (UTC): + IP Address: + Server Name: + Object ID: + Object Department ID: + User Agent: + Message: + Data: + Not recorded + Department Settings Changed + User Added + User Removed + Group Added + Group Removed + Group Changed + Unit Added + Unit Removed + Unit Changed + Profile Updated + Permissions Changed + Subscription Updated + Subscription Created + Subscription Cancelled + Subscription Billing Info Updated + Call Reactivated + User Account Deleted + Add-on Subscription Modified + Department Deletion Requested + Department Deletion Request Cancelled + Static Shift Deleted + Static Shift Updated + Custom Status Added + Custom Status Removed + Custom Status Updated + Custom Status Detail Updated + Call Type Added + Call Type Edited + Call Type Removed + Call Priority Added + Call Priority Edited + Call Priority Removed + Unit Type Added + Unit Type Edited + Unit Type Removed + Certification Type Added + Certification Type Edited + Certification Type Removed + Document Category Added + Document Category Edited + Document Category Removed + Document Added + Document Edited + Document Removed + Note Category Added + Note Category Edited + Note Category Removed + Note Added + Note Edited + Note Removed + Contact Added + Contact Edited + Contact Removed + Contact Category Added + Contact Category Edited + Contact Category Removed + Contact Note Type Added + Contact Note Type Edited + Contact Note Type Removed + Workflow Created + Workflow Updated + Workflow Deleted + Workflow Step Added + Workflow Step Updated + Workflow Step Deleted + Workflow Credential Added + Workflow Credential Updated + Workflow Credential Deleted + Contact Verification Code Sent + Contact Verification Confirmed + Contact Verification Failed + Two-Factor Enabled + Two-Factor Disabled + Two-Factor Login Verified + Two-Factor Recovery Code Used + Two-Factor Step-Up Verified + SSO Config Created + SSO Config Updated + SSO Config Deleted + SSO Login Succeeded + SSO Login Failed + SSO User Provisioned + SCIM User Created + SCIM User Updated + SCIM User Deactivated + SCIM User Deleted + SCIM Auth Failed + SCIM User Reactivated + SCIM Group Listed + SCIM User Listed + SCIM User Retrieved + SCIM Bearer Token Provisioned + SCIM Bearer Token Rotated + UDF Definition Created + UDF Definition Updated + UDF Definition Deleted + UDF Field Added + UDF Field Updated + UDF Field Removed + UDF Field Values Saved + Route Created + Route Updated + Route Deleted + Route Started + Route Completed + Route Cancelled + Route Paused + Route Resumed + Route Stop Checked In + Route Stop Checked Out + Route Stop Skipped + Route Deviation Detected + Route Deviation Acknowledged + Check-In Timer Configuration Created + Check-In Timer Configuration Updated + Check-In Timer Configuration Deleted + Check-In Timer Override Created + Check-In Timer Override Updated + Check-In Timer Override Deleted + Check-In Performed + Check-In Timer Enabled on Call + Check-In Timer Disabled on Call + Calendar Event Check-In + Calendar Event Check-Out + Calendar Check-In Times Updated + Calendar Check-In Deleted + Admin Calendar Check-In + Log Created + Log Deleted + Communication Test Created + Communication Test Updated + Communication Test Deleted + Communication Test Run Started + Weather Alert Source Created + Weather Alert Source Updated + Weather Alert Source Deleted + Weather Alert Source Enabled + Weather Alert Source Disabled + Weather Alert Zone Created + Weather Alert Zone Updated + Weather Alert Zone Deleted + Weather Alert Zone Enabled + Weather Alert Zone Disabled + Weather Alert Settings Changed + Feature Flag Changed + Feature Flag Override Changed + Unit Tracking Device Created + Unit Tracking Device Updated + Unit Tracking Device Disabled + Unit Tracking Device Deleted + Unit Tracking Credential Created + Unit Tracking Credential Rotated + Unit Tracking Credential Revoked + Department Deletion Executed + Chat Message Deleted by Moderator + Chat User Muted + Chat User Unmuted + Chat User Banned + Chat User Unbanned + Chat Channel Locked + Chat Channel Unlocked + Chat Channel Archived + Chat Flag Resolved + Chat Settings Changed + Chat Export Requested + Chat Export Downloaded + Moderation Report Submitted + Moderation Request Reopened + Moderation Request Completed + Moderation Evidence Downloaded + Password Reset by Administrator + User Sign-In Sessions Revoked + Data Protection Step-Up Exemptions Changed + Contact Pre-Plan Added + Contact Pre-Plan Updated + Contact Pre-Plan Removed + Contact Attachment Added + Contact Attachment Removed + Checklist Definition Added + Checklist Definition Updated + Checklist Definition Published + Checklist Definition Retired + Checklist Definition Removed + Checklist Completion Started + Checklist Progress Saved + Checklist Completion Submitted + Checklist Witness Attested + Checklist File Added + Checklist File Removed + Checklist Schedule Added + Checklist Schedule Updated + Checklist Occurrence Missed + Checklist Occurrence Skipped + Checklist Reminder Settings Updated + Work Order Changed + Inventory Changed + Billing Profile Changed + Rate Card Changed + Invoice Created + Invoice Updated + Invoice Sent + Invoice Voided + Invoice Payment Recorded + Department Billing Identity Changed + Payment Connection Connected + Payment Connection Disconnected + Payment Connection Revoked + Payment Connection Action Required + Invoice Payment Request Created + Invoice Payment Refunded + Invoice Payment Disputed + Payment Webhook Rejected + Invoice Payment Request Failed + Invoice Payment Request Expired + Certification Added + Certification Updated + Certification Removed + Certification Status Changed + Certification Verified + Certification Credit Added + Certification Credit Removed + Role Certification Requirement Changed + Department Certification Settings Changed + Role Member Added + Role Member Removed + Role Member Removed by Certification + Unit Certification Added + Unit Certification Updated + Unit Certification Removed + Unit Certification Status Changed + Deployment Created + Deployment Updated + Deployment Status Changed + Deployment Roster Changed + Deployment Equipment Changed + Deployment Attachment Added + Deployment Attachment Removed + Time Report Created + Time Report Updated + Time Report Submitted + Time Report Approved + Time Report Voided + Deployment Expense Added + Deployment Expense Updated + Deployment Expense Removed + Rate Schedule Created + Rate Schedule Updated + Rate Schedule Deleted + Rate Schedule Entry Changed + Rate Premium Changed + Service Contract Created + Service Contract Updated + Service Contract Status Changed + Service Contract Deleted + Compliance Document Added + Compliance Document Updated + Compliance Document Removed + Bid Created + Bid Updated + Bid Sent + Bid Accepted + Bid Declined + Bid Withdrawn + Bid Expired + Bid Converted + Bid Deleted + Time Report Billed + Deployment Invoice Generated + Cal OES MARS Agency Profile Changed + Cal OES MARS Resource Profile Changed + Cal OES MARS Rate Profile Changed + Cal OES MARS Rate Draft Built + Cal OES MARS Rate Reviewed + Cal OES MARS Agreement Changed + Cal OES MARS Agreement Observed + Cal OES MARS Work Item Prepared + Cal OES MARS Work Item Validated + Cal OES MARS Reimbursement Calculated + Cal OES MARS Work Item Opened for Handoff + Cal OES MARS External Status Observed + Cal OES MARS Invoice Approved + Cal OES MARS Invoice Rejected + Cal OES MARS Payment Reconciled + Cal OES MARS Work Item Deleted + Workforce Employer Profile Changed + Workforce Establishment Changed + Workforce Employment Changed + Workforce Compensation Changed + Workforce Annual Pay Data Imported + Pay Data Demographics Changed + Pay Data Report Created + Pay Data Report Validated + Pay Data Report Frozen + Pay Data Report Exported + Pay Data Report Marked Certified + Pay Data Report Corrected + Resource Cost Profile Changed + Resource Usage Changed + Field Cost Run Created + Field Cost Run Frozen + User Reactivated + Department Configuration Changed + Admin Assist Review Changed + Admin Assist Diagnostic Access + AI Dispatch Settings Updated + Admin Assist Plan Access + Security Policy Changed Permission Note Value @@ -37,9 +394,15 @@ Roles No Roles N/A + Everyone + Department Admins + Department and Group Admins + Department Admins and Select Roles + Department, Group Admins and Select Roles Security and Permissions + Home Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. @@ -169,6 +532,15 @@ SCIM Confirm Delete Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Invalid provider type. + An SSO configuration for {0} already exists. Use Edit to modify it. + OIDC client ID is required. + OIDC authority must be a valid HTTPS URL. + An IdP signing certificate is required to validate SAML assertions. + The IdP single sign-on URL must be a valid HTTPS URL. + {0} SSO configuration created successfully. + SSO configuration updated successfully. + {0} SSO configuration deleted. Cancel Delete @@ -180,6 +552,8 @@ Step 1 Provider & Basic Settings Identity Provider Protocol + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + SAML 2.0 — Most enterprise / government IdPs Enable this SSO configuration Only one configuration per provider type is active at a time. Allow local password login @@ -189,6 +563,7 @@ Enable SCIM 2.0 provisioning Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. Default Rank for Auto-Provisioned Users + (No default rank) Optional. Applied only when Auto-Provision creates a new member. Step 2 — OIDC OpenID Connect Settings @@ -217,6 +592,13 @@ Resgrid will periodically fetch this URL to stay up to date with IdP certificates. ACS URL (stored) Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Redirect URI for sign-in started from Resgrid (enter this into your IdP) + Add this alongside any redirect URI you already registered; older Resgrid app versions keep using theirs. + Redirect URIs for sign-in in the Resgrid apps (enter each into your IdP) + Each app, and each app's web edition, returns to its own address, so register every one. Older app versions, and apps whose sign-in is not started from Resgrid, use these. + {0} (web) + IdP single sign-on URL + The IdP's SAML sign-in address (HTTP-Redirect binding). Needed for sign-in started from the Resgrid apps and website. IdP Signing Certificate (PEM) Certificate stored Encrypted at rest. Leave blank to keep the existing certificate. @@ -341,6 +723,9 @@ Allowed IP Ranges (CIDR) One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. Data Classification Level + Unclassified + CUI - Controlled Unclassified Information + Confidential Used for compliance reporting and audit logs. Password Policy Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. @@ -457,6 +842,192 @@ Controls who can transfer inventory between locations. Defaults to department administrators. + + Second-Factor Methods + + + Choose which verification methods count as multi-factor authentication in this department. Authenticator app (TOTP) codes are always accepted, so turning a method off never locks anyone out; members who used it are asked to verify again. + + + Only the department's managing member can change these settings. + + + Passkeys are not yet available on this system. These choices take effect when they are. + + + Not yet available + + + Accept passkeys for sign-in and security checks + + + A passkey registered in the same app counts as MFA for sign-in, switching departments, and sensitive actions. + + + Accept passkeys for protected data + + + A passkey counts as MFA for revealing and editing protected data. Changing this ends current protected-data access, so members verify again. + + + Accept approval from the Responder app + + + Where passkeys are accepted, a member can approve a sign-in or protected-data request with the passkey in their Responder app. Never used for security changes. Changing this ends current protected-data access. + + + Accept your identity provider's MFA for sign-in and security checks + + + Requires a tested MFA mapping on your SSO configuration. + + + Accept your identity provider's MFA for protected data + + + Requires a tested MFA mapping on your SSO configuration. Changing this ends current protected-data access. + + + Your SSO configuration has no tested MFA mapping. Save a mapping and complete its test before accepting your identity provider's MFA. + + + To accept your identity provider's MFA, first verify with your authenticator app or a passkey. Identity provider MFA cannot approve this change. + + + Let a recent sign-in verification open protected data + + + Members who verified MFA when signing in do not have to verify again to reveal protected data within the step-up window. Changing this ends current protected-data access. + + + Let a shared-device unlock open protected data + + + On shared vehicle tablets and workstations, the operator's fresh unlock verification counts for revealing protected data. Changing this ends current protected-data access. + + + Shared vehicle and workstation devices + + + Shared vehicle tablets and dispatch workstations lock when no one uses them and end with the shift. Operators unlock with their own authenticator app, passkey or Responder approval, never a password kept on the device. A stricter value here also applies to sessions already running. + + + Only the department's managing member can change the shared-device policy. + + + Shared-device mode is not available on this deployment yet. These values are kept, and it cannot be required for another app until it is. + + + Lock after this many idle minutes + + + 1 to {0} minutes. Only the operator's own activity counts; background updates and incoming alerts do not. + + + End the shift after this many hours + + + 1 to {0} hours after sign-in, whatever the activity. The next shift signs in again. + + + Always use shared mode for + + + Sessions of these apps in this department always lock when idle and end with the shift, whatever the installation is set to. Sign-ins that do not say which app they are count too, so integrations that sign in with a password also lock. Use app versions that support shared mode. + + + Unit + + + IC (Command) + + + Dispatch + + + Choose an idle lock from 1 to {0} minutes. + + + Choose a shift length from 1 to {0} hours. + + + Shared-device mode is not available on this deployment yet, so it cannot be required for another app. + + + Identity provider MFA + + + Tell Resgrid how your identity provider reports that it verified a member with MFA. Where this department accepts your identity provider's MFA, a sign-in or verification that carries one of these values counts as MFA, and members need no Resgrid authenticator for it. Every change must pass a test sign-in before it takes effect. + + + Set up and enable an SSO configuration first. + + + In effect: version {0}, tested {1}. + + + Not in effect: version {0} has not passed its test yet. + + + No mapping is saved. + + + What Resgrid asks for + + + What counts as MFA + + + One value per line. Values are matched exactly, including case. A response needs at least one listed value. + + + acr_values to request (OIDC) + + + claims request (OIDC, JSON) + + + RequestedAuthnContext class references (SAML) + + + amr values (OIDC) + + + acr values (OIDC) + + + acrs values (OIDC, authentication context) + + + AuthnContextClassRef values (SAML) + + + Save mapping + + + Remove mapping + + + Mapping saved. It takes effect once it passes a test sign-in. + + + Mapping removed. Your identity provider's MFA no longer counts in this department. + + + The mapping cannot be used: {0} + + + Changing the mapping needs a recent verification with your authenticator app or a passkey. Your identity provider's MFA cannot approve this change. + + + Identity provider MFA mapping + + + Test with a sign-in + + + The test sends you to your identity provider now, asking for MFA. When it returns a value the mapping counts, this version takes effect. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx index 197ed75c9..b487cec65 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx @@ -8,6 +8,363 @@ Seguridad y Permisos Aquí puede establecer los permisos para su departamento. Registros de Auditoría + Filtrar por tipo de auditoría + Todos los tipos de auditoría + Busque por nombre de usuario, ID de usuario o de auditoría, dirección de correo electrónico, fecha/hora o tipo de auditoría. La búsqueda y la ordenación se aplican dentro del tipo de auditoría seleccionado. + Buscar en los registros de auditoría: + Nombre, ID, correo electrónico, fecha/hora o tipo + Fecha y hora + Tipo + Registrado por + Resultado + Mensaje + Términos de búsqueda + Acciones + Exitoso + Fallido + Ver + Mostrando _START_ a _END_ de _TOTAL_ entradas + Mostrando 0 a 0 de 0 entradas + (filtrado de _MAX_ entradas en total) + Mostrar _MENU_ entradas + Cargando... + No hay entradas en el registro de auditoría + No se encontraron entradas coincidentes en el registro de auditoría + Primero + Último + Siguiente + Anterior + : activar para ordenar la columna de forma ascendente + : activar para ordenar la columna de forma descendente + Sistema + Desconocida + Ver registro de auditoría + Registro de auditoría + ID del registro de auditoría: + ID del departamento: + Tipo de auditoría: + ID del tipo de registro: + Descripción del tipo: + Resultado: + Registrado por: + ID de usuario: + Fecha de registro (local): + Fecha de registro (UTC): + Dirección IP: + Nombre del servidor: + ID del objeto: + ID del departamento del objeto: + Agente de usuario: + Mensaje: + Datos: + Sin registrar + Configuración del departamento cambiada + Usuario agregado + Usuario quitado + Grupo agregado + Grupo quitado + Grupo cambiado + Unidad agregada + Unidad quitada + Unidad cambiada + Perfil actualizado + Permisos cambiados + Suscripción actualizada + Suscripción creada + Suscripción cancelada + Información de facturación de la suscripción actualizada + Llamada reactivada + Cuenta de usuario eliminada + Suscripción de complemento modificada + Eliminación del departamento solicitada + Solicitud de eliminación del departamento cancelada + Turno estático eliminado + Turno estático actualizado + Estado personalizado agregado + Estado personalizado quitado + Estado personalizado actualizado + Detalle de estado personalizado actualizado + Tipo de llamada agregado + Tipo de llamada editado + Tipo de llamada quitado + Prioridad de llamada agregada + Prioridad de llamada editada + Prioridad de llamada quitada + Tipo de unidad agregado + Tipo de unidad editado + Tipo de unidad quitado + Tipo de certificación agregado + Tipo de certificación editado + Tipo de certificación quitado + Categoría de documento agregada + Categoría de documento editada + Categoría de documento quitada + Documento agregado + Documento editado + Documento quitado + Categoría de nota agregada + Categoría de nota editada + Categoría de nota quitada + Nota agregada + Nota editada + Nota quitada + Contacto agregado + Contacto editado + Contacto quitado + Categoría de contacto agregada + Categoría de contacto editada + Categoría de contacto quitada + Tipo de nota de contacto agregado + Tipo de nota de contacto editado + Tipo de nota de contacto quitado + Flujo de trabajo creado + Flujo de trabajo actualizado + Flujo de trabajo eliminado + Paso de flujo de trabajo agregado + Paso de flujo de trabajo actualizado + Paso de flujo de trabajo eliminado + Credencial de flujo de trabajo agregada + Credencial de flujo de trabajo actualizada + Credencial de flujo de trabajo eliminada + Código de verificación de contacto enviado + Verificación de contacto confirmada + Verificación de contacto fallida + Autenticación de dos factores habilitada + Autenticación de dos factores deshabilitada + Inicio de sesión con dos factores verificado + Código de recuperación de dos factores usado + Acción sensible verificada con dos factores + Configuración SSO creada + Configuración SSO actualizada + Configuración SSO eliminada + Inicio de sesión SSO exitoso + Inicio de sesión SSO fallido + Usuario SSO aprovisionado + Usuario SCIM creado + Usuario SCIM actualizado + Usuario SCIM desactivado + Usuario SCIM eliminado + Autenticación SCIM fallida + Usuario SCIM reactivado + Grupos SCIM listados + Usuarios SCIM listados + Usuario SCIM consultado + Token portador SCIM aprovisionado + Token portador SCIM rotado + Definición UDF creada + Definición UDF actualizada + Definición UDF eliminada + Campo UDF agregado + Campo UDF actualizado + Campo UDF quitado + Valores de campos UDF guardados + Ruta creada + Ruta actualizada + Ruta eliminada + Ruta iniciada + Ruta completada + Ruta cancelada + Ruta pausada + Ruta reanudada + Entrada en parada de ruta registrada + Salida de parada de ruta registrada + Parada de ruta omitida + Desviación de ruta detectada + Desviación de ruta reconocida + Configuración de temporizador de registro creada + Configuración de temporizador de registro actualizada + Configuración de temporizador de registro eliminada + Anulación de temporizador de registro creada + Anulación de temporizador de registro actualizada + Anulación de temporizador de registro eliminada + Registro de entrada realizado + Temporizador de registro habilitado en llamada + Temporizador de registro deshabilitado en llamada + Registro de entrada en evento de calendario + Registro de salida de evento de calendario + Horarios de registro de entrada del calendario actualizados + Registro de entrada del calendario eliminado + Registro de entrada en calendario por administrador + Bitácora creada + Bitácora eliminada + Prueba de comunicación creada + Prueba de comunicación actualizada + Prueba de comunicación eliminada + Ejecución de prueba de comunicación iniciada + Fuente de alertas meteorológicas creada + Fuente de alertas meteorológicas actualizada + Fuente de alertas meteorológicas eliminada + Fuente de alertas meteorológicas habilitada + Fuente de alertas meteorológicas deshabilitada + Zona de alertas meteorológicas creada + Zona de alertas meteorológicas actualizada + Zona de alertas meteorológicas eliminada + Zona de alertas meteorológicas habilitada + Zona de alertas meteorológicas deshabilitada + Configuración de alertas meteorológicas cambiada + Indicador de función cambiado + Anulación de indicador de función cambiada + Dispositivo de rastreo de unidad creado + Dispositivo de rastreo de unidad actualizado + Dispositivo de rastreo de unidad deshabilitado + Dispositivo de rastreo de unidad eliminado + Credencial de rastreo de unidad creada + Credencial de rastreo de unidad rotada + Credencial de rastreo de unidad revocada + Eliminación del departamento ejecutada + Mensaje de chat eliminado por moderador + Usuario de chat silenciado + Silencio de usuario de chat levantado + Usuario de chat bloqueado + Usuario de chat desbloqueado + Canal de chat bloqueado + Canal de chat desbloqueado + Canal de chat archivado + Reporte de chat resuelto + Configuración del chat cambiada + Exportación del chat solicitada + Exportación del chat descargada + Reporte de moderación enviado + Solicitud de moderación reabierta + Solicitud de moderación completada + Evidencia de moderación descargada + Contraseña restablecida por administrador + Sesiones iniciadas del usuario revocadas + Exenciones de verificación de protección de datos cambiadas + Plan previo de contacto agregado + Plan previo de contacto actualizado + Plan previo de contacto quitado + Adjunto de contacto agregado + Adjunto de contacto quitado + Definición de lista de verificación agregada + Definición de lista de verificación actualizada + Definición de lista de verificación publicada + Definición de lista de verificación retirada + Definición de lista de verificación quitada + Ejecución de lista de verificación iniciada + Progreso de lista de verificación guardado + Ejecución de lista de verificación enviada + Lista de verificación atestiguada por testigo + Archivo de lista de verificación agregado + Archivo de lista de verificación quitado + Programación de lista de verificación agregada + Programación de lista de verificación actualizada + Revisión de lista de verificación no realizada + Revisión de lista de verificación omitida + Configuración de recordatorios de listas de verificación actualizada + Orden de trabajo cambiada + Inventario cambiado + Perfil de facturación cambiado + Tarifa cambiada + Factura creada + Factura actualizada + Factura enviada + Factura anulada + Pago de factura registrado + Identidad de facturación del departamento cambiada + Conexión de pago establecida + Conexión de pago desconectada + Conexión de pago revocada + Acción requerida en conexión de pago + Solicitud de pago de factura creada + Pago de factura reembolsado + Pago de factura disputado + Webhook de pago rechazado + Solicitud de pago de factura fallida + Solicitud de pago de factura vencida + Certificación agregada + Certificación actualizada + Certificación quitada + Estado de certificación cambiado + Certificación verificada + Crédito de certificación agregado + Crédito de certificación quitado + Requisito de certificación de rol cambiado + Configuración de certificaciones del departamento cambiada + Miembro de rol agregado + Miembro de rol quitado + Miembro de rol quitado por certificación + Certificación de unidad agregada + Certificación de unidad actualizada + Certificación de unidad quitada + Estado de certificación de unidad cambiado + Despliegue creado + Despliegue actualizado + Estado del despliegue cambiado + Dotación del despliegue cambiada + Equipo del despliegue cambiado + Archivo de despliegue agregado + Archivo de despliegue quitado + Parte de horas creado + Parte de horas actualizado + Parte de horas enviado + Parte de horas aprobado + Parte de horas anulado + Gasto de despliegue agregado + Gasto de despliegue actualizado + Gasto de despliegue quitado + Tabla de tarifas creada + Tabla de tarifas actualizada + Tabla de tarifas eliminada + Entrada de tabla de tarifas cambiada + Recargo de tarifa cambiado + Contrato de servicio creado + Contrato de servicio actualizado + Estado de contrato de servicio cambiado + Contrato de servicio eliminado + Documento de cumplimiento agregado + Documento de cumplimiento actualizado + Documento de cumplimiento quitado + Oferta creada + Oferta actualizada + Oferta enviada + Oferta aceptada + Oferta rechazada + Oferta retirada + Oferta vencida + Oferta convertida + Oferta eliminada + Parte de horas facturado + Factura de despliegue generada + Perfil de agencia Cal OES MARS cambiado + Perfil de recurso Cal OES MARS cambiado + Perfil de tarifas Cal OES MARS cambiado + Borrador de tarifas Cal OES MARS elaborado + Tarifa Cal OES MARS revisada + Acuerdo Cal OES MARS cambiado + Acuerdo Cal OES MARS observado + Elemento de trabajo Cal OES MARS preparado + Elemento de trabajo Cal OES MARS validado + Reembolso Cal OES MARS calculado + Elemento de trabajo Cal OES MARS abierto para entrega + Estado externo Cal OES MARS observado + Factura Cal OES MARS aprobada + Factura Cal OES MARS rechazada + Pago Cal OES MARS conciliado + Elemento de trabajo Cal OES MARS eliminado + Perfil de empleador de plantilla cambiado + Establecimiento de plantilla cambiado + Empleo de plantilla cambiado + Compensación de plantilla cambiada + Datos anuales de pago de plantilla importados + Registro demográfico de datos salariales cambiado + Informe de datos salariales creado + Informe de datos salariales validado + Informe de datos salariales congelado + Informe de datos salariales exportado + Informe de datos salariales marcado como certificado + Informe de datos salariales corregido + Perfil de costo de recurso cambiado + Uso de recursos cambiado + Cálculo de costos de campo creado + Cálculo de costos de campo congelado + Usuario reactivado + Parámetros del departamento cambiados + Revisión de Admin Assist cambiada + Acceso a diagnóstico de Admin Assist + Configuración del despacho con IA actualizada + Acceso a plan de cambios de Admin Assist + Política de seguridad cambiada Permiso Nota Valor @@ -33,7 +390,13 @@ Roles Sin Roles N/A + Todos + Administradores del departamento + Administradores del departamento y de grupos + Administradores del departamento y roles seleccionados + Administradores del departamento y de grupos, y roles seleccionados Seguridad y Permisos + Inicio Aquí puede establecer los permisos para su departamento. Quién puede Agregar Usuarios Esta opción determina quién puede agregar usuarios al departamento. @@ -55,8 +418,8 @@ Esta opción determina quién puede crear entradas de calendario. Por defecto, todos pueden hacerlo. Quién puede Crear Notas Esta opción determina quién puede crear notas. Por defecto, todos pueden crear notas. - Quién puede Agregar Entradas de Registro - Esta opción determina quién puede agregar entradas de registro. Por defecto, todos pueden hacerlo. + Quién puede Agregar Entradas de Bitácora + Esta opción determina quién puede agregar entradas de bitácora. Por defecto, todos pueden hacerlo. Quién puede Crear Turnos Esta opción determina quién puede crear y editar turnos. Por defecto, solo los administradores del departamento. Quién puede Ver Información Personal @@ -137,6 +500,15 @@ SCIM Confirmar Eliminación ¿Está seguro de que desea eliminar la configuración SSO de {0}? Esto no se puede deshacer. + Tipo de proveedor no válido. + Ya existe una configuración SSO para {0}. Use Editar para modificarla. + El ID de cliente OIDC es obligatorio. + La autoridad OIDC debe ser una URL HTTPS válida. + Se requiere un certificado de firma del IdP para validar las aserciones SAML. + La URL de inicio de sesión único del IdP debe ser una URL HTTPS válida. + Configuración SSO de {0} creada exitosamente. + Configuración SSO actualizada exitosamente. + Configuración SSO de {0} eliminada. Cancelar Eliminar @@ -148,6 +520,8 @@ Paso 1 Proveedor y Configuración Básica Protocolo del Proveedor de Identidad + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + SAML 2.0 — La mayoría de los IdP empresariales y gubernamentales Habilitar esta configuración SSO Solo una configuración por tipo de proveedor está activa a la vez. Permitir inicio de sesión con contraseña local @@ -157,6 +531,7 @@ Habilitar aprovisionamiento SCIM 2.0 Permite a su IdP crear/actualizar/desactivar miembros automáticamente. Después de guardar, vaya a la página de Configuración SCIM para generar un token portador. Rango Predeterminado para Usuarios Auto-Aprovisionados + (Sin rango predeterminado) Opcional. Se aplica solo cuando Auto-Aprovisionamiento crea un nuevo miembro. Paso 2 — OIDC Configuración de OpenID Connect @@ -185,6 +560,13 @@ Resgrid obtendrá periódicamente esta URL para mantenerse actualizado con los certificados del IdP. ACS URL (almacenada) Completada automáticamente desde la URL generada arriba. Modifíquela solo si configuró una ACS URL diferente en su IdP. + URI de redirección para el inicio de sesión desde Resgrid (introdúzcalo en su proveedor de identidad) + Añádalo junto a cualquier URI de redirección que ya haya registrado; las versiones anteriores de las aplicaciones de Resgrid siguen usando las suyas. + URI de redirección para el inicio de sesión en las aplicaciones de Resgrid (introduzca cada uno en su proveedor de identidad) + Cada aplicación, y la edición web de cada una, vuelve a su propia dirección, así que regístrelos todos. Los usan las versiones anteriores de las aplicaciones y las aplicaciones cuyo inicio de sesión no comienza desde Resgrid. + {0} (web) + URL de inicio de sesión único del proveedor de identidad + La dirección de inicio de sesión SAML del proveedor de identidad (enlace HTTP-Redirect). Necesaria para los inicios de sesión desde las aplicaciones y el sitio web de Resgrid. Certificado de Firma del IdP (PEM) Certificado almacenado Cifrado en reposo. Déjelo en blanco para mantener el certificado existente. @@ -309,13 +691,21 @@ Rangos de IP Permitidos (CIDR) Un bloque CIDR por línea, o separado por comas. Vacío = permitir todos. Nivel de Clasificación de Datos + Sin clasificar + CUI - Información no clasificada controlada + Confidencial Utilizado para informes de cumplimiento y registros de auditoría. Política de Contraseñas Las políticas de contraseña se aplican solo a los inicios de sesión locales (no SSO). Caducidad de Contraseña (días) 0 = las contraseñas nunca caducan. 90 es típico para entornos CUI. Longitud Mínima de Contraseña - 0 = predeterminado del sistema (8). NIST recomienda 12+; CUI requiere 14+. + Mínimo 8 (valor predeterminado del sistema). Las políticas del departamento solo pueden aumentarlo. NIST recomienda 12+; CUI requiere 14+. + Complejidad de contraseña exigida por el sistema + Todas las cuentas de Resgrid deben cumplir el siguiente estándar de complejidad de contraseña. No se puede desactivar mediante la política del departamento. + Al menos 8 caracteres (o la longitud mínima del departamento indicada arriba) + Al menos un dígito (número) + Al menos una letra mayúscula y una minúscula Requerir complejidad de contraseña Exige al menos una letra mayúscula, un dígito y un carácter especial. Preajustes rápidos @@ -331,6 +721,8 @@ La contraseña debe contener al menos una letra minúscula. La contraseña debe tener al menos {0} caracteres. La longitud mínima de la contraseña no puede ser menor que el valor predeterminado del sistema de 8 caracteres. + Eliminar entradas de bitácora + Quién de su departamento puede eliminar entradas de bitácora Use Calendar Sync Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. Dispatch App Login @@ -416,6 +808,192 @@ Controla quién puede trasladar inventario entre ubicaciones. Por defecto, se permite a los administradores del departamento. + + Métodos de segundo factor + + + Elija qué métodos de verificación cuentan como autenticación multifactor en este departamento. Los códigos de la aplicación de autenticación (TOTP) siempre se aceptan, así que desactivar un método nunca deja a nadie fuera; a los miembros que lo usaron se les pide verificar de nuevo. + + + Solo el miembro gestor del departamento puede cambiar esta configuración. + + + Las claves de acceso aún no están disponibles en este sistema. Estas opciones se aplicarán cuando lo estén. + + + Aún no disponible + + + Aceptar claves de acceso para el inicio de sesión y las comprobaciones de seguridad + + + Una clave de acceso registrada en la misma aplicación cuenta como MFA para iniciar sesión, cambiar de departamento y realizar acciones sensibles. + + + Aceptar claves de acceso para los datos protegidos + + + Una clave de acceso cuenta como MFA para mostrar y editar datos protegidos. Cambiar esto finaliza el acceso actual a los datos protegidos, así que los miembros verifican de nuevo. + + + Aceptar la aprobación desde la aplicación Responder + + + Donde se aceptan claves de acceso, un miembro puede aprobar un inicio de sesión o una solicitud de datos protegidos con la clave de acceso de su aplicación Responder. Nunca se usa para cambios de seguridad. Cambiar esto finaliza el acceso actual a los datos protegidos. + + + Aceptar la MFA de su proveedor de identidad para el inicio de sesión y las comprobaciones de seguridad + + + Requiere una asignación de MFA probada en su configuración de SSO. + + + Aceptar la MFA de su proveedor de identidad para los datos protegidos + + + Requiere una asignación de MFA probada en su configuración de SSO. Cambiar esto finaliza el acceso actual a los datos protegidos. + + + Su configuración de SSO no tiene una asignación de MFA probada. Guarde una asignación y complete su prueba antes de aceptar la MFA de su proveedor de identidad. + + + Para aceptar la MFA de su proveedor de identidad, verifique primero con su aplicación de autenticación o una clave de acceso. La MFA del proveedor de identidad no puede aprobar este cambio. + + + Permitir que una verificación reciente al iniciar sesión abra los datos protegidos + + + Los miembros que verificaron la MFA al iniciar sesión no tienen que volver a verificar para mostrar datos protegidos dentro de la ventana de verificación. Cambiar esto finaliza el acceso actual a los datos protegidos. + + + Permitir que el desbloqueo de un dispositivo compartido abra los datos protegidos + + + En tabletas de vehículos y puestos de trabajo compartidos, la verificación reciente de desbloqueo del operador cuenta para mostrar datos protegidos. Cambiar esto finaliza el acceso actual a los datos protegidos. + + + Dispositivos compartidos de vehículos y puestos de trabajo + + + Las tabletas de vehículos y los puestos de despacho compartidos se bloquean cuando nadie los usa y finalizan con el turno. Los operadores desbloquean con su propia aplicación de autenticación, clave de acceso o aprobación de Responder, nunca con una contraseña guardada en el dispositivo. Un valor más estricto aquí también se aplica a las sesiones en curso. + + + Solo el miembro gestor del departamento puede cambiar la política de dispositivos compartidos. + + + El modo de dispositivo compartido aún no está disponible en esta instalación. Estos valores se conservan y no se puede exigir para otra aplicación hasta que lo esté. + + + Bloquear tras estos minutos de inactividad + + + De 1 a {0} minutos. Solo cuenta la actividad del propio operador; las actualizaciones en segundo plano y las alertas entrantes no. + + + Finalizar el turno tras estas horas + + + De 1 a {0} horas después de iniciar sesión, sea cual sea la actividad. El siguiente turno inicia sesión de nuevo. + + + Usar siempre el modo compartido para + + + Las sesiones de estas aplicaciones en este departamento siempre se bloquean por inactividad y finalizan con el turno, sea cual sea la configuración de la instalación. También cuentan los inicios de sesión que no indican su aplicación, por lo que las integraciones que inician sesión con contraseña también se bloquean. Use versiones de las aplicaciones compatibles con el modo compartido. + + + Unit + + + IC (Mando) + + + Dispatch + + + Elija un bloqueo por inactividad de 1 a {0} minutos. + + + Elija una duración de turno de 1 a {0} horas. + + + El modo de dispositivo compartido aún no está disponible en esta instalación, así que no se puede exigir para otra aplicación. + + + MFA del proveedor de identidad + + + Indique a Resgrid cómo informa su proveedor de identidad de que verificó a un miembro con MFA. Donde este departamento acepta la MFA de su proveedor de identidad, un inicio de sesión o una verificación que incluya uno de estos valores cuenta como MFA, y los miembros no necesitan un autenticador de Resgrid para ello. Cada cambio debe superar un inicio de sesión de prueba antes de aplicarse. + + + Primero configure y active una configuración de SSO. + + + En vigor: versión {0}, probada el {1}. + + + No está en vigor: la versión {0} aún no ha superado su prueba. + + + No hay ninguna asignación guardada. + + + Qué solicita Resgrid + + + Qué cuenta como MFA + + + Un valor por línea. Los valores deben coincidir exactamente, incluidas mayúsculas y minúsculas. Una respuesta necesita al menos uno de los valores. + + + acr_values que se solicitan (OIDC) + + + Solicitud claims (OIDC, JSON) + + + Referencias de clase de RequestedAuthnContext (SAML) + + + Valores amr (OIDC) + + + Valores acr (OIDC) + + + Valores acrs (OIDC, contexto de autenticación) + + + Valores AuthnContextClassRef (SAML) + + + Guardar asignación + + + Quitar asignación + + + Asignación guardada. Se aplicará cuando supere un inicio de sesión de prueba. + + + Asignación quitada. La MFA de su proveedor de identidad ya no cuenta en este departamento. + + + La asignación no se puede usar: {0} + + + Cambiar la asignación requiere una verificación reciente con su aplicación de autenticación o una clave de acceso. La MFA de su proveedor de identidad no puede aprobar este cambio. + + + Asignación de MFA del proveedor de identidad + + + Probar con un inicio de sesión + + + La prueba le envía ahora a su proveedor de identidad solicitando MFA. Cuando devuelva un valor que la asignación cuente, esta versión entrará en vigor. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx index a56786c30..b8a868030 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Sécurité et autorisations - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Vous pouvez définir ici les autorisations de votre département, par exemple quels utilisateurs ou rôles peuvent créer des appels, ou qui est autorisé à créer et retirer des utilisateurs. Les modifications des autorisations prendront effet à la prochaine connexion à l'application web Resgrid. - Audit Logs + Journaux d'audit + + + Filtrer par type d'audit + + + Tous les types d'audit + + + Recherchez par nom d'utilisateur, ID d'utilisateur ou d'audit, adresse e-mail, date/heure ou type d'audit. La recherche et le tri s'appliquent au sein du type d'audit sélectionné. + + + Rechercher dans les journaux d'audit : + + + Nom, ID, e-mail, date/heure ou type + + + Horodatage + + + Type + + + Enregistré par + + + Résultat + + + Message + + + Termes de recherche + + + Actions + + + Réussi + + + Échoué + + + Voir + + + Affichage des entrées _START_ à _END_ sur _TOTAL_ + + + Affichage des entrées 0 à 0 sur 0 + + + (filtrées parmi _MAX_ entrées au total) + + + Afficher _MENU_ entrées + + + Chargement… + + + Aucune entrée de journal d'audit + + + Aucune entrée de journal d'audit correspondante + + + Premier + + + Dernier + + + Suivant + + + Précédent + + + : activer pour trier la colonne par ordre croissant + + + : activer pour trier la colonne par ordre décroissant + + + Système + + + Inconnu + + + Voir le journal d'audit + + + Journal d'audit + + + ID du journal d'audit : + + + ID du département : + + + Type d'audit : + + + ID du type de journal : + + + Description du type : + + + Résultat : + + + Enregistré par : + + + ID d'utilisateur : + + + Enregistré le (heure locale) : + + + Enregistré le (UTC) : + + + Adresse IP : + + + Nom du serveur : + + + ID de l'objet : + + + ID du département de l'objet : + + + Agent utilisateur : + + + Message : + + + Données : + + + Non enregistré + + + Paramètres du département modifiés + + + Utilisateur ajouté + + + Utilisateur retiré + + + Groupe ajouté + + + Groupe retiré + + + Groupe modifié + + + Unité ajoutée + + + Unité retirée + + + Unité modifiée + + + Profil mis à jour + + + Autorisations modifiées + + + Abonnement mis à jour + + + Abonnement créé + + + Abonnement annulé + + + Informations de facturation de l'abonnement mises à jour + + + Appel réactivé + + + Compte utilisateur supprimé + + + Abonnement au module complémentaire modifié + + + Suppression du département demandée + + + Demande de suppression du département annulée + + + Quart statique supprimé + + + Quart statique mis à jour + + + Statut personnalisé ajouté + + + Statut personnalisé retiré + + + Statut personnalisé mis à jour + + + Détail de statut personnalisé mis à jour + + + Type d'appel ajouté + + + Type d'appel modifié + + + Type d'appel retiré + + + Priorité d'appel ajoutée + + + Priorité d'appel modifiée + + + Priorité d'appel retirée + + + Type d'unité ajouté + + + Type d'unité modifié + + + Type d'unité retiré + + + Type de certification ajouté + + + Type de certification modifié + + + Type de certification retiré + + + Catégorie de documents ajoutée + + + Catégorie de documents modifiée + + + Catégorie de documents retirée + + + Document ajouté + + + Document modifié + + + Document retiré + + + Catégorie de notes ajoutée + + + Catégorie de notes modifiée + + + Catégorie de notes retirée + + + Note ajoutée + + + Note modifiée + + + Note retirée + + + Contact ajouté + + + Contact modifié + + + Contact retiré + + + Catégorie de contacts ajoutée + + + Catégorie de contacts modifiée + + + Catégorie de contacts retirée + + + Type de note de contact ajouté + + + Type de note de contact modifié + + + Type de note de contact retiré + + + Flux de travail créé + + + Flux de travail mis à jour + + + Flux de travail supprimé + + + Étape de flux de travail ajoutée + + + Étape de flux de travail mise à jour + + + Étape de flux de travail supprimée + + + Identifiant de flux de travail ajouté + + + Identifiant de flux de travail mis à jour + + + Identifiant de flux de travail supprimé + + + Code de vérification des coordonnées envoyé + + + Vérification des coordonnées confirmée + + + Vérification des coordonnées échouée + + + Authentification à deux facteurs activée + + + Authentification à deux facteurs désactivée + + + Connexion à deux facteurs vérifiée + + + Code de récupération à deux facteurs utilisé + + + Authentification renforcée à deux facteurs vérifiée + + + Configuration SSO créée + + + Configuration SSO mise à jour + + + Configuration SSO supprimée + + + Connexion SSO réussie + + + Connexion SSO échouée + + + Utilisateur SSO approvisionné + + + Utilisateur SCIM créé + + + Utilisateur SCIM mis à jour + + + Utilisateur SCIM désactivé + + + Utilisateur SCIM supprimé + + + Authentification SCIM échouée + + + Utilisateur SCIM réactivé + + + Groupes SCIM listés + + + Utilisateurs SCIM listés + + + Utilisateur SCIM récupéré + + + Jeton Bearer SCIM approvisionné + + + Jeton Bearer SCIM renouvelé + + + Définition UDF créée + + + Définition UDF mise à jour + + + Définition UDF supprimée + + + Champ UDF ajouté + + + Champ UDF mis à jour + + + Champ UDF retiré + + + Valeurs de champs UDF enregistrées + + + Itinéraire créé + + + Itinéraire mis à jour + + + Itinéraire supprimé + + + Itinéraire démarré + + + Itinéraire terminé + + + Itinéraire annulé + + + Itinéraire mis en pause + + + Itinéraire repris + + + Arrivée pointée à un arrêt d'itinéraire + + + Départ pointé d'un arrêt d'itinéraire + + + Arrêt d'itinéraire ignoré + + + Écart d'itinéraire détecté + + + Écart d'itinéraire acquitté + + + Configuration de minuterie de pointage créée + + + Configuration de minuterie de pointage mise à jour + + + Configuration de minuterie de pointage supprimée + + + Remplacement de minuterie de pointage créé + + + Remplacement de minuterie de pointage mis à jour + + + Remplacement de minuterie de pointage supprimé + + + Pointage effectué + + + Minuterie de pointage activée sur un appel + + + Minuterie de pointage désactivée sur un appel + + + Arrivée pointée à un événement du calendrier + + + Départ pointé d'un événement du calendrier + + + Horaires de pointage du calendrier mis à jour + + + Pointage du calendrier supprimé + + + Pointage au calendrier effectué par un administrateur + + + Journal créé + + + Journal supprimé + + + Test de communication créé + + + Test de communication mis à jour + + + Test de communication supprimé + + + Exécution du test de communication démarrée + + + Source d'alertes météorologiques créée + + + Source d'alertes météorologiques mise à jour + + + Source d'alertes météorologiques supprimée + + + Source d'alertes météorologiques activée + + + Source d'alertes météorologiques désactivée + + + Zone d'alertes météorologiques créée + + + Zone d'alertes météorologiques mise à jour + + + Zone d'alertes météorologiques supprimée + + + Zone d'alertes météorologiques activée + + + Zone d'alertes météorologiques désactivée + + + Paramètres des alertes météorologiques modifiés + + + Indicateur de fonctionnalité modifié + + + Remplacement d'indicateur de fonctionnalité modifié + + + Appareil de suivi d'unité créé + + + Appareil de suivi d'unité mis à jour + + + Appareil de suivi d'unité désactivé + + + Appareil de suivi d'unité supprimé + + + Identifiant de suivi d'unité créé + + + Identifiant de suivi d'unité renouvelé + + + Identifiant de suivi d'unité révoqué + + + Suppression du département exécutée + + + Message de discussion supprimé par un modérateur + + + Utilisateur de discussion mis en sourdine + + + Sourdine levée pour un utilisateur de discussion + + + Utilisateur de discussion banni + + + Bannissement levé pour un utilisateur de discussion + + + Canal de discussion verrouillé + + + Canal de discussion déverrouillé + + + Canal de discussion archivé + + + Signalement de discussion résolu + + + Paramètres de discussion modifiés + + + Export de discussion demandé + + + Export de discussion téléchargé + + + Signalement de modération soumis + + + Demande de modération rouverte + + + Demande de modération terminée + + + Preuve de modération téléchargée + + + Mot de passe réinitialisé par un administrateur + + + Sessions de connexion de l'utilisateur révoquées + + + Exemptions de vérification de la protection des données modifiées + + + Plan d'intervention du contact ajouté + + + Plan d'intervention du contact mis à jour + + + Plan d'intervention du contact retiré + + + Pièce jointe du contact ajoutée + + + Pièce jointe du contact retirée + + + Définition de liste de contrôle ajoutée + + + Définition de liste de contrôle mise à jour + + + Définition de liste de contrôle publiée + + + Définition de liste de contrôle mise hors service + + + Définition de liste de contrôle retirée + + + Réalisation de liste de contrôle démarrée + + + Progression de liste de contrôle enregistrée + + + Réalisation de liste de contrôle soumise + + + Liste de contrôle attestée par un témoin + + + Fichier de liste de contrôle ajouté + + + Fichier de liste de contrôle retiré + + + Planification de liste de contrôle ajoutée + + + Planification de liste de contrôle mise à jour + + + Contrôle planifié manqué + + + Contrôle planifié ignoré + + + Paramètres de rappel des listes de contrôle mis à jour + + + Ordre de travail modifié + + + Inventaire modifié + + + Profil de facturation modifié + + + Grille tarifaire de facturation modifiée + + + Facture créée + + + Facture mise à jour + + + Facture envoyée + + + Facture annulée + + + Paiement de facture enregistré + + + Identité de facturation du département modifiée + + + Compte de paiement connecté + + + Compte de paiement déconnecté + + + Accès au compte de paiement révoqué + + + Action requise sur le compte de paiement + + + Demande de paiement de facture créée + + + Paiement de facture remboursé + + + Paiement de facture contesté + + + Webhook de paiement rejeté + + + Demande de paiement de facture échouée + + + Demande de paiement de facture expirée + + + Certification ajoutée + + + Certification mise à jour + + + Certification retirée + + + Statut de certification modifié + + + Certification vérifiée + + + Crédit de certification ajouté + + + Crédit de certification retiré + + + Exigence de certification du rôle modifiée + + + Paramètres des certifications du département modifiés + + + Membre du rôle ajouté + + + Membre du rôle retiré + + + Membre du rôle retiré pour certification échue + + + Certification d'unité ajoutée + + + Certification d'unité mise à jour + + + Certification d'unité retirée + + + Statut de certification d'unité modifié + + + Déploiement créé + + + Déploiement mis à jour + + + Statut du déploiement modifié + + + Effectif du déploiement modifié + + + Équipement du déploiement modifié + + + Fichier de déploiement ajouté + + + Fichier de déploiement retiré + + + Rapport de temps créé + + + Rapport de temps mis à jour + + + Rapport de temps soumis + + + Rapport de temps approuvé + + + Rapport de temps annulé + + + Dépense de déploiement ajoutée + + + Dépense de déploiement mise à jour + + + Dépense de déploiement retirée + + + Grille tarifaire créée + + + Grille tarifaire mise à jour + + + Grille tarifaire supprimée + + + Entrée de grille tarifaire modifiée + + + Majoration tarifaire modifiée + + + Contrat de service créé + + + Contrat de service mis à jour + + + Statut du contrat de service modifié + + + Contrat de service supprimé + + + Document de conformité ajouté + + + Document de conformité mis à jour + + + Document de conformité retiré + + + Offre créée + + + Offre mise à jour + + + Offre envoyée + + + Offre acceptée + + + Offre refusée + + + Offre retirée + + + Offre expirée + + + Offre convertie + + + Offre supprimée + + + Rapport de temps facturé + + + Facture de déploiement générée + + + Profil d'agence Cal OES MARS modifié + + + Profil de ressource Cal OES MARS modifié + + + Profil de taux Cal OES MARS modifié + + + Brouillon de taux Cal OES MARS établi + + + Taux Cal OES MARS examiné + + + Accord Cal OES MARS modifié + + + Accord Cal OES MARS observé + + + Élément Cal OES MARS préparé + + + Élément Cal OES MARS validé + + + Remboursement Cal OES MARS calculé + + + Élément Cal OES MARS ouvert pour transfert + + + Statut externe Cal OES MARS observé + + + Facture Cal OES MARS approuvée + + + Facture Cal OES MARS rejetée + + + Paiement Cal OES MARS rapproché + + + Élément Cal OES MARS supprimé + + + Profil employeur modifié + + + Établissement modifié + + + Emploi modifié + + + Rémunération modifiée + + + Données annuelles de paie importées + + + Données démographiques de paie modifiées + + + Rapport de données de paie créé + + + Rapport de données de paie validé + + + Rapport de données de paie figé + + + Rapport de données de paie exporté + + + Rapport de données de paie marqué comme certifié + + + Rapport de données de paie corrigé + + + Profil de coût de ressource modifié + + + Utilisation des ressources modifiée + + + Calcul de coûts d'intervention créé + + + Calcul de coûts d'intervention figé + + + Utilisateur réactivé + + + Configuration du département modifiée + + + Examen Admin Assist modifié + + + Accès au diagnostic Admin Assist accordé + + + Paramètres de la répartition par IA mis à jour + + + Accès au plan Admin Assist accordé + + + Politique de sécurité modifiée - Permission + Autorisation Note - Value + Valeur Rôles - Two-Factor Authentication (2FA) Enforcement + Obligation de l'authentification à deux facteurs (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Exigez la 2FA par application d'authentification pour les administrateurs. Lorsque cette option est activée, les administrateurs qui n'ont pas configuré la 2FA sont redirigés vers l'inscription avant de pouvoir accéder aux fonctions d'administration. - Require 2FA for Admins + Exiger la 2FA pour les administrateurs - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Détermine quels utilisateurs de niveau administrateur doivent s'inscrire à la 2FA par application d'authentification (Google Authenticator, Microsoft Authenticator, Authy, etc.). - Disabled (no requirement) + Désactivé (aucune exigence) - Department Admins + Managing User + Administrateurs du département + membre gestionnaire - Department Admins + Managing User + Group Admins + Administrateurs du département + membre gestionnaire + administrateurs de groupe - 2FA enforcement setting saved. + Réglage d'obligation de la 2FA enregistré. - Failed to save 2FA enforcement setting. + Échec de l'enregistrement du réglage d'obligation de la 2FA. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Impossible d'activer l'obligation de la 2FA : le membre gestionnaire de ce département n'a pas activé l'authentification à deux facteurs sur son compte. Le membre gestionnaire doit activer la 2FA avant que ce réglage puisse être activé. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Impossible d'activer l'obligation de la 2FA : vous n'avez pas activé l'authentification à deux facteurs sur votre propre compte. Vous devez activer la 2FA avant de pouvoir l'exiger des autres. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Impossible d'activer l'obligation de la 2FA : ni vous ni le membre gestionnaire n'avez activé l'authentification à deux facteurs. Vous devez tous deux activer la 2FA avant que ce réglage puisse être activé. - This setting is locked until the above conditions are met. + Ce réglage est verrouillé tant que les conditions ci-dessus ne sont pas remplies. - Permission + Autorisation Note - Selection + Sélection - Group Only + Groupe uniquement Rôles - No Roles + Aucun rôle N/A + + Tout le monde + + + Administrateurs du département + + + Administrateurs du département et des groupes + + + Administrateurs du département et rôles sélectionnés + + + Administrateurs du département et des groupes, et rôles sélectionnés + - Security and Permissions + Sécurité et autorisations + + + Accueil - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Vous pouvez définir ici les autorisations de votre département, par exemple quels utilisateurs ou rôles peuvent créer des appels, ou qui est autorisé à créer et retirer des utilisateurs. Les modifications des autorisations prendront effet à la prochaine connexion à l'application web Resgrid. - Who can Add Users + Qui peut ajouter des utilisateurs - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Cette option détermine qui peut ajouter des utilisateurs/du personnel au département. Par défaut, seuls les administrateurs du département (et le membre gestionnaire) peuvent ajouter des utilisateurs. Les administrateurs de groupe peuvent toutefois aussi être autorisés à ajouter des utilisateurs (uniquement dans le groupe dont ils sont administrateurs). - Who can Remove Users + Qui peut retirer des utilisateurs - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Cette option détermine qui peut retirer des utilisateurs/du personnel du département. Par défaut, seuls les administrateurs du département (et le membre gestionnaire) peuvent retirer des utilisateurs. Les administrateurs de groupe peuvent toutefois aussi être autorisés à retirer des utilisateurs (uniquement parmi les utilisateurs du groupe dont ils sont administrateurs). - Who can Create Calls + Qui peut créer des appels - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Cette option détermine qui peut créer manuellement des appels dans le système Resgrid. Par défaut, tout le monde peut créer des appels. - Who can Delete Calls + Qui peut supprimer des appels - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Cette option détermine qui peut supprimer des appels du système Resgrid. Par défaut, tout le monde peut supprimer des appels. - Who can Close Calls + Qui peut clôturer des appels - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Cette option détermine qui peut clôturer des appels dans le système Resgrid. Par défaut, tout le monde peut clôturer des appels. - Who can Add Data To Calls + Qui peut ajouter des données aux appels - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Cette option détermine qui peut ajouter des données, comme des images, des notes et des fichiers, aux appels dans le système Resgrid. Par défaut, tout le monde peut ajouter des données aux appels. - Who can Create Trainings + Qui peut créer des formations - This option determines who can create trainings. By default only Department Admins can create trainings. + Cette option détermine qui peut créer des formations. Par défaut, seuls les administrateurs du département peuvent créer des formations. - Who can Add Documents + Qui peut ajouter des documents - This option determines who can add documents. By default Everyone can add documents. + Cette option détermine qui peut ajouter des documents. Par défaut, tout le monde peut ajouter des documents. - Who can Create Calendar Entries + Qui peut créer des entrées de calendrier - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Cette option détermine qui peut créer des entrées de calendrier. Par défaut, tout le monde peut créer des entrées de calendrier. - Who can Create Notes + Qui peut créer des notes - This option determines who can create notes. By default Everyone can create notes. + Cette option détermine qui peut créer des notes. Par défaut, tout le monde peut créer des notes. - Who can Add Log Entries + Qui peut ajouter des entrées de journal - This option determines who can add log entries. By default Everyone can add log entries. + Cette option détermine qui peut ajouter des entrées de journal. Par défaut, tout le monde peut ajouter des entrées de journal. - Who can Create Shifts + Qui peut créer des quarts - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Cette option détermine qui peut créer et modifier des quarts. Par défaut, seuls les administrateurs du département peuvent créer et modifier des quarts. - Who can View Personal Info + Qui peut voir les informations personnelles - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Cette option détermine qui peut voir les informations personnelles identifiables (PII) du personnel dans le système, par exemple l'adresse e-mail, les numéros de téléphone, etc. Par défaut, tout le monde peut voir ces informations. - Who can Adjust Inventory + Qui peut ajuster l'inventaire - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Cette option détermine qui peut ajuster les niveaux de stock de l'inventaire dans le système. Par défaut, tout le monde peut ajuster l'inventaire. - Who can see the Location of Personnel + Qui peut voir la position du personnel - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Cette option détermine qui peut voir la position du personnel sur les cartes. Pour limiter cette option aux administrateurs de groupe et aux rôles d'un groupe, vous devez cocher l'option Groupe uniquement. - Who can see the Location of Units + Qui peut voir la position des unités - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Cette option détermine qui peut voir la position des unités sur les cartes. Pour limiter cette option aux administrateurs de groupe et aux rôles d'un groupe, vous devez cocher l'option Groupe uniquement. - Who can send messages + Qui peut envoyer des messages - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Cette option détermine qui peut créer et envoyer des messages (messagerie interne). Par défaut, tout le monde peut créer et envoyer des messages. - Who can view users + Qui peut voir les utilisateurs - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + Par défaut, tous les utilisateurs peuvent voir tous les autres utilisateurs du système. Cette option vous permet de limiter qui peut voir les utilisateurs du système. - Who can view units + Qui peut voir les unités - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + Par défaut, tous les utilisateurs peuvent voir toutes les unités du système. Cette option vous permet de limiter qui peut voir les unités du système. - Who can view Contacts + Qui peut voir les contacts - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + Par défaut, tous les utilisateurs peuvent voir tous les contacts du système. Cette option vous permet de limiter qui peut voir les contacts du système. Un utilisateur qui ne peut pas voir les contacts ne pourra pas non plus les ajouter à un appel. - Who can edit or create Contacts + Qui peut modifier ou créer des contacts - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + Par défaut, tous les utilisateurs peuvent créer et modifier des contacts dans le système. Cette option vous permet de limiter qui peut créer ou modifier des contacts dans le système. - Who can delete Contacts + Qui peut supprimer des contacts - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + Par défaut, tous les utilisateurs peuvent supprimer des contacts dans le système. Cette option vous permet de limiter qui peut supprimer des contacts dans le système. - Who can Create/Edit Workflows + Qui peut créer/modifier des flux de travail - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Cette option détermine qui peut créer, modifier et supprimer des flux de travail et leurs étapes. Par défaut, seuls les administrateurs du département peuvent gérer les flux de travail. - Who can Manage Workflow Credentials + Qui peut gérer les identifiants des flux de travail - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Cette option détermine qui peut créer, modifier et supprimer les identifiants chiffrés utilisés par les actions des flux de travail (p. ex. mots de passe SMTP, clés API). Par défaut, seuls les administrateurs du département peuvent gérer les identifiants. - Who can View Workflow Runs + Qui peut voir les exécutions de flux de travail - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Cette option détermine qui peut voir l'historique d'exécution des flux de travail, les journaux d'exécution et les tableaux de bord d'état. Par défaut, seuls les administrateurs du département peuvent voir les exécutions de flux de travail. - Single Sign-On (SSO) & SCIM + Authentification unique (SSO) et SCIM - Single Sign-On & Identity Provisioning + Authentification unique et approvisionnement des identités SSO / SCIM - Security Policy + Politique de sécurité - Add OIDC Config + Ajouter une configuration OIDC - Add SAML 2.0 Config + Ajouter une configuration SAML 2.0 - What is SSO & SCIM? + Qu'est-ce que le SSO et le SCIM ? - Single Sign-On (SSO) + Authentification unique (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Permettez aux membres du département de se connecter avec leur identité d'entreprise existante (Microsoft Entra ID, Okta, Google Workspace, etc.) au lieu d'un mot de passe Resgrid distinct. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC — Protocole moderne, idéal pour Entra, Okta, Auth0, Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 — Largement pris en charge par les IdP gouvernementaux et d'entreprise - SCIM 2.0 Provisioning + Approvisionnement SCIM 2.0 - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Synchronisez automatiquement les utilisateurs depuis votre fournisseur d'identité. Lorsque vous ajoutez ou retirez du personnel dans votre annuaire d'entreprise, Resgrid reflète automatiquement ces changements — aucune étape manuelle d'invitation ou de retrait. - Auto-create new members when added in your IdP + Créer automatiquement les nouveaux membres ajoutés dans votre IdP - Disable/remove members when deprovisioned + Désactiver/retirer les membres déprovisionnés - Keep names & email addresses in sync + Synchroniser les noms et adresses e-mail - Security Policy + Politique de sécurité - Enforce department-wide compliance controls alongside SSO: + Appliquez des contrôles de conformité à l'échelle du département en complément du SSO : - Mandate MFA for all members + Imposer la MFA à tous les membres - Restrict login to SSO only (disable passwords) + Limiter la connexion au SSO uniquement (désactiver les mots de passe) - Limit logins to specific IP CIDR ranges + Limiter les connexions à des plages d'adresses IP CIDR spécifiques - Set password expiration & complexity rules + Définir des règles d'expiration et de complexité des mots de passe - Classify data level (Unclassified / CUI / Confidential) + Classer le niveau des données (Non classifié / CUI / Confidentiel) - Mobile App Discovery URL + URL de découverte pour l'application mobile - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + L'application mobile Resgrid utilise cette URL pour découvrir vos paramètres SSO avant d'afficher l'écran de connexion. Partagez-la avec votre équipe mobile si nécessaire. - Copy + Copier - Copied to clipboard! + Copié dans le presse-papiers ! - SSO Configurations + Configurations SSO - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Aucune configuration SSO pour le moment. Utilisez les boutons ci-dessus pour ajouter une configuration OIDC ou SAML 2.0. - Provider + Fournisseur - Identifier / Endpoint + Identifiant / point de terminaison Statut - Local Login + Connexion locale - Auto-Provision + Approvisionnement automatique SCIM - Created + Date de création Actions - Enabled + Activé - Disabled + Désactivé Oui - SSO Only + SSO uniquement - On + Activé - Off + Désactivé Actif - No Token + Aucun jeton - Off + Désactivé Modifier @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Confirmer la suppression - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Voulez-vous vraiment supprimer la configuration SSO {0} ? Cette action est irréversible. + + + Type de fournisseur non valide. + + + Une configuration SSO pour {0} existe déjà. Utilisez Modifier pour la mettre à jour. + + + L'ID client OIDC est requis. + + + L'autorité OIDC doit être une URL HTTPS valide. + + + Un certificat de signature de l'IdP est requis pour valider les assertions SAML. + + + L'URL d'authentification unique de l'IdP doit être une URL HTTPS valide. + + + Configuration SSO {0} créée avec succès. + + + Configuration SSO mise à jour avec succès. + + + Configuration SSO {0} supprimée. Annuler @@ -453,10 +1569,10 @@ Supprimer - New SSO Configuration + Nouvelle configuration SSO - Edit SSO Configuration + Modifier la configuration SSO Nouveau @@ -465,226 +1581,256 @@ Modifier - Step 1 + Étape 1 - Provider & Basic Settings + Fournisseur et paramètres de base - Identity Provider Protocol + Protocole du fournisseur d'identité + + + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + + + SAML 2.0 — La plupart des IdP d'entreprise et gouvernementaux - Enable this SSO configuration + Activer cette configuration SSO - Only one configuration per provider type is active at a time. + Une seule configuration par type de fournisseur peut être active à la fois. - Allow local password login + Autoriser la connexion locale par mot de passe - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + Si cette case est cochée, les utilisateurs peuvent toujours se connecter avec leur nom d'utilisateur et leur mot de passe en plus du SSO. Décochez-la et activez l'option Exiger le SSO de la politique de sécurité pour imposer la connexion par SSO uniquement. - Auto-provision new users + Approvisionner automatiquement les nouveaux utilisateurs - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Crée automatiquement un compte Resgrid lorsqu'un utilisateur s'authentifie par SSO pour la première fois et qu'aucune adresse e-mail correspondante n'est trouvée. Laissez désactivé pour exiger d'abord une invitation manuelle. - Enable SCIM 2.0 provisioning + Activer l'approvisionnement SCIM 2.0 - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Permet à votre IdP de créer, mettre à jour et désactiver automatiquement des membres. Après l'enregistrement, rendez-vous sur la page de configuration SCIM pour générer un jeton Bearer. - Default Rank for Auto-Provisioned Users + Grade par défaut des utilisateurs approvisionnés automatiquement + + + (Aucun grade par défaut) - Optional. Applied only when Auto-Provision creates a new member. + Facultatif. Appliqué uniquement lorsque l'approvisionnement automatique crée un nouveau membre. - Step 2 — OIDC + Étape 2 — OIDC - OpenID Connect Settings + Paramètres OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Où trouver ces valeurs : dans votre IdP, enregistrez Resgrid comme client public (PKCE, aucun secret client requis pour le mobile) ou comme application web (avec secret pour les flux côté serveur). Copiez l'ID client et l'URL de l'émetteur depuis l'application enregistrée. - Authority / Issuer URL + URL de l'autorité / de l'émetteur https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Exemples : Entra ID : https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta : https://{your-domain}.okta.com/oauth2/default | Google : https://accounts.google.com - Client ID + ID client xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + L'identifiant client public attribué par votre IdP. Peut être affiché sans risque — ce n'est pas un secret. - Client Secret + Secret client - Secret stored + Secret enregistré - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Chiffré au repos avec la clé propre à votre département. Laissez vide si vous utilisez PKCE (mobile/SPA — aucun secret nécessaire). - Leave blank to keep the existing secret unchanged. + Laissez vide pour conserver le secret existant. - Step 2 — SAML 2.0 + Étape 2 — SAML 2.0 - SAML 2.0 Settings + Paramètres SAML 2.0 - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Où trouver ces valeurs : dans votre IdP, créez une nouvelle application SAML. Saisissez dans votre IdP l'ID d'entité SP et l'URL ACS indiqués ci-dessous, puis collez ici l'URL des métadonnées ou le certificat de l'IdP. - SP Entity ID (enter this into your IdP) + ID d'entité SP (à saisir dans votre IdP) - ACS URL (Assertion Consumer Service — enter this into your IdP) + URL ACS (Assertion Consumer Service — à saisir dans votre IdP) - This URL uses an encrypted department token — never exposes your plain department code. + Cette URL utilise un jeton de département chiffré — votre code de département en clair n'est jamais exposé. - SP Entity ID (editable) + ID d'entité SP (modifiable) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Doit correspondre exactement à ce que vous avez saisi dans l'IdP. - IdP Metadata URL + URL des métadonnées de l'IdP https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid récupérera périodiquement cette URL pour rester à jour avec les certificats de l'IdP. - ACS URL (stored) + URL ACS (enregistrée) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Renseignée automatiquement à partir de l'URL générée ci-dessus. Ne la modifiez que si vous avez configuré une autre URL ACS dans votre IdP. + + + URI de redirection pour la connexion lancée depuis Resgrid (à saisir dans votre fournisseur d'identité) + + + Ajoutez-le à côté des URI de redirection déjà enregistrées ; les anciennes versions des applications Resgrid continuent d'utiliser les leurs. + + + URI de redirection pour la connexion dans les applications Resgrid (à saisir une par une dans votre fournisseur d'identité) + + + Chaque application, et l'édition web de chacune, revient à sa propre adresse : enregistrez-les donc toutes. Les anciennes versions des applications, et les applications dont la connexion n'est pas lancée depuis Resgrid, les utilisent. + + + {0} (web) + + + URL d'authentification unique du fournisseur d'identité + + + L'adresse de connexion SAML du fournisseur d'identité (liaison HTTP-Redirect). Nécessaire pour les connexions lancées depuis les applications et le site web Resgrid. - IdP Signing Certificate (PEM) + Certificat de signature de l'IdP (PEM) - Certificate stored + Certificat enregistré - Encrypted at rest. Leave blank to keep the existing certificate. + Chiffré au repos. Laissez vide pour conserver le certificat existant. - SP Signing Key (PEM, optional) + Clé de signature SP (PEM, facultative) - Key stored + Clé enregistrée - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Chiffrée au repos. Laissez vide si votre IdP n'exige pas de requêtes signées. - Step 3 + Étape 3 - Attribute Mapping + Correspondance des attributs - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Associez les noms de revendications (claims) envoyés par votre IdP aux champs utilisateur de Resgrid. Laissez vide pour utiliser automatiquement les URI de revendication standard. La valeur est le nom de la revendication tel qu'émis par votre IdP. - Attribute Mapping JSON + JSON de correspondance des attributs - Must be valid JSON. Use the quick-fill buttons on the right. + Doit être un JSON valide. Utilisez les boutons de remplissage rapide à droite. - Quick-fill presets + Préréglages de remplissage rapide - Click to auto-fill the mapping JSON with known defaults for your IdP. + Cliquez pour remplir automatiquement le JSON de correspondance avec les valeurs par défaut connues pour votre IdP. - Supported Resgrid field keys + Clés de champ Resgrid prises en charge - Key + Clé Description - User's email address + Adresse e-mail de l'utilisateur - Given / first name + Prénom - Surname / family name + Nom de famille - Unique IdP subject (for account linking) + Sujet (subject) unique de l'IdP (pour l'association des comptes) - Create Configuration + Créer la configuration - Save Changes + Enregistrer les modifications Annuler - SCIM 2.0 Setup + Configuration SCIM 2.0 - SCIM 2.0 Provisioning Setup + Configuration de l'approvisionnement SCIM 2.0 - SCIM Setup + Configuration SCIM - Back to SSO + Retour au SSO - Important — Copy your SCIM Bearer Token Now + Important — copiez votre jeton Bearer SCIM maintenant - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Ce jeton n'est affiché qu'une seule fois et ne peut pas être récupéré par la suite. Copiez-le immédiatement et collez-le dans la configuration SCIM de votre fournisseur d'identité. Si vous le perdez, cliquez sur Faire pivoter le jeton pour en générer un nouveau. - Copy Token + Copier le jeton - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Ce jeton est stocké chiffré dans la base de données Resgrid avec la clé de chiffrement propre à votre département. - SCIM Status + Statut SCIM - Provider + Fournisseur - SCIM Enabled + SCIM activé Oui @@ -693,61 +1839,61 @@ Non - Bearer Token + Jeton Bearer - Configured + Configuré - Not Set + Non défini - Rotate SCIM Token + Faire pivoter le jeton SCIM - Generate SCIM Token + Générer un jeton SCIM - Rotating generates a new token and invalidates the old one. + La rotation génère un nouveau jeton et invalide l'ancien. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Voulez-vous vraiment continuer ? Toute intégration SCIM existante cessera de fonctionner jusqu'à ce que vous mettiez à jour le jeton dans votre IdP. - SCIM Connector Settings + Paramètres du connecteur SCIM - Enter these values into your identity provider's SCIM provisioning configuration. + Saisissez ces valeurs dans la configuration d'approvisionnement SCIM de votre fournisseur d'identité. - Setting + Paramètre - Value + Valeur - SCIM Connector Base URL + URL de base du connecteur SCIM - Authentication Method + Méthode d'authentification - Authorization Header + En-tête d'autorisation - Custom Header Name + Nom de l'en-tête personnalisé - Custom Header Value + Valeur de l'en-tête personnalisé - Supported Resources + Ressources prises en charge - Supported Update Method + Méthode de mise à jour prise en charge - Step-by-Step Setup Guide + Guide de configuration étape par étape Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Autre / générique - Any SCIM 2.0 compatible client can be configured with the following: + Tout client compatible SCIM 2.0 peut être configuré avec les éléments suivants : - Resgrid SCIM field mapping + Correspondance des champs SCIM de Resgrid - Parameter + Paramètre - SCIM field + Champ SCIM - Resgrid effect + Effet dans Resgrid @@ -814,112 +1960,136 @@ Pour des instructions étape par étape spécifiques à votre IdP (Okta, Microsoft Entra ID, Google Workspace) et la gestion des jetons, consultez la page de configuration SCIM pour chaque configuration. - Department Security Policy + Politique de sécurité du département - Department Security Policy + Politique de sécurité du département - Security Policy + Politique de sécurité - Back to SSO + Retour au SSO - Security policy saved successfully. + Politique de sécurité enregistrée avec succès. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Aucune configuration SSO active. Vous pouvez configurer les politiques de mot de passe ci-dessous, mais l'option Exiger le SSO ne peut pas être activée tant que vous n'avez pas de configuration SSO active. - Configure SSO → + Configurer le SSO → - Authentication Controls + Contrôles d'authentification - Require MFA for all members + Exiger la MFA pour tous les membres - Members who have not enrolled in MFA will be prompted to do so on next login. + Les membres qui ne sont pas inscrits à la MFA seront invités à le faire lors de leur prochaine connexion. - Require SSO — disable password login + Exiger le SSO — désactiver la connexion par mot de passe - Requires active SSO config + Nécessite une configuration SSO active - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Attention : l'activation de cette option bloque toutes les connexions par nom d'utilisateur et mot de passe. Assurez-vous qu'au moins un administrateur a testé avec succès la connexion SSO avant de l'activer. - Session Timeout (minutes) + Expiration de session (minutes) - 0 = use system default. 480 = 8 hours. + 0 = utiliser la valeur par défaut du système. 480 = 8 heures. - Max Concurrent Sessions per User + Nombre max. de sessions simultanées par utilisateur - 0 = unlimited. Government environments typically set 1. + 0 = illimité. Les environnements gouvernementaux fixent généralement cette valeur à 1. - Allowed IP Ranges (CIDR) + Plages d'adresses IP autorisées (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Un bloc CIDR par ligne, ou séparés par des virgules. Vide = tout autoriser. Les connexions provenant de l'extérieur de ces plages seront refusées. - Data Classification Level + Niveau de classification des données + + + Non classifié + + + CUI - Informations non classifiées contrôlées + + + Confidentiel - Used for compliance reporting and audit logs. + Utilisé pour les rapports de conformité et les journaux d'audit. - Password Policy + Politique de mot de passe - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + Les politiques de mot de passe s'appliquent uniquement aux connexions locales (hors SSO). Si vous activez l'option Exiger le SSO ci-dessus, ces paramètres n'ont aucun effet. - Password Expiration (days) + Expiration du mot de passe (jours) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = les mots de passe n'expirent jamais. 90 est une valeur courante pour les environnements CUI. - Minimum Password Length + Longueur minimale du mot de passe - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Minimum 8 (valeur par défaut du système). Les politiques du département peuvent uniquement l'augmenter. Le NIST recommande 12 ou plus ; CUI exige 14 ou plus. + + + Complexité des mots de passe imposée par le système + + + Tous les comptes Resgrid doivent respecter la norme de complexité des mots de passe suivante. Elle ne peut pas être désactivée par la politique du département. + + + Au moins 8 caractères (ou la longueur minimale du département définie ci-dessus) + + + Au moins un chiffre + + + Au moins une lettre majuscule et une lettre minuscule - Require password complexity + Exiger la complexité du mot de passe - Enforces at least one uppercase letter, one digit, and one special character. + Impose au moins une lettre majuscule, un chiffre et un caractère spécial. - Quick presets + Préréglages rapides - Government / CUI + Gouvernement / CUI - Standard Enterprise + Entreprise standard Minimal - Save Security Policy + Enregistrer la politique de sécurité Annuler - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Impossible d'activer la connexion par SSO uniquement : aucune configuration SSO active n'existe. Créez et activez d'abord une configuration SSO. Le mot de passe ne peut pas être vide. Le mot de passe doit contenir au moins un chiffre. @@ -927,32 +2097,34 @@ Le mot de passe doit contenir au moins une lettre minuscule. Le mot de passe doit comporter au moins {0} caractères. La longueur minimale du mot de passe ne peut pas être inférieure à la valeur par défaut du système de 8 caractères. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. - Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Supprimer des entrées de journal + Qui, dans votre département, est autorisé à supprimer des entrées de journal + Utiliser la synchronisation du calendrier + Détermine qui peut activer et utiliser les URL d'abonnement au calendrier pour synchroniser les événements du calendrier Resgrid avec des applications de calendrier externes. + Connexion à l'application Dispatch + Détermine qui peut se connecter à l'application Dispatch. Dispatch affiche les communications privées du commandement, des unités et des intervenants pour chaque intervention ; restreignez donc cette autorisation si vos membres ne sont pas tous des régulateurs. + Connexion à l'application de commandement + Détermine qui peut agir en tant que commandant : se connecter à l'application IC, établir le commandement de l'intervention sur un appel et consulter les tableaux de commandement. Choisir une valeur plus restreinte que Tout le monde permet aussi aux personnes sélectionnées d'aider à gérer n'importe quel tableau de commandement (affecter et déplacer des moyens, gérer les minuteries et le suivi du personnel) sans y occuper de poste ICS — pratique pour permettre aux régulateurs de prêter main-forte depuis l'application Dispatch. Tant que la valeur est Tout le monde, les actions sur le tableau restent réservées au commandant de l'intervention et aux rôles ICS attribués. + Protection avancée des données + Ces autorisations déterminent qui peut travailler avec les données chiffrées (protégées) lorsque le module complémentaire Protection avancée des données est actif. Chaque affichage ou modification exige en plus une vérification à deux facteurs récente ; ces réglages déterminent qui peut ne serait-ce que la tenter. Contrairement à la plupart des autorisations Resgrid, les valeurs non définies appliquent par défaut la sélection restrictive affichée. + Gérer les paramètres de protection des données + Qui peut modifier les paramètres de Protection avancée des données, comme la fenêtre de vérification et les options de contenu des notifications. L'achat, l'inscription et la résiliation restent toujours réservés au membre gestionnaire du département. + Afficher les données d'appel protégées + Qui peut afficher les champs d'appel protégés (nature, adresse, coordonnées, notes) après une vérification à deux facteurs. Tout le monde par défaut, car le personnel qui intervient doit pouvoir lire l'alerte. + Modifier les données d'appel protégées + Qui peut modifier les champs d'appel protégés après une vérification à deux facteurs. Tout le monde par défaut, pour correspondre au déroulement normal des appels. + Afficher les données protégées du personnel + Qui peut afficher les informations protégées du personnel (matricules, contacts d'urgence) après une vérification à deux facteurs. Par défaut : administrateurs du département. + Afficher les données de contact protégées + Qui peut afficher les informations de contact protégées (noms, numéros de téléphone, numéros d'identité officiels, emplacements) après une vérification à deux facteurs. Par défaut : administrateurs du département. + Afficher les données opérationnelles protégées + Qui peut afficher le contenu opérationnel protégé (journaux, soumissions de formulaires, notes et pièces jointes du commandement de l'intervention) après une vérification à deux facteurs. Par défaut : administrateurs du département et administrateurs de groupe. + Exporter les données protégées + Qui peut exporter des données contenant des champs protégés. Les exportations sortent de la protection de Resgrid ; chaque exportation fait donc l'objet d'un audit distinct. Par défaut : administrateurs du département ; Tout le monde n'est volontairement pas proposé. + Configurer la diffusion des données protégées + Qui peut modifier la façon dont le contenu protégé sort de Resgrid par notification push, SMS, e-mail et message vocal. Par défaut : administrateurs du département ; Tout le monde n'est volontairement pas proposé. + Accès d'urgence (bris de glace) + Qui peut utiliser la procédure d'accès d'urgence auditée aux données protégées. Elle ne fonctionne que si le bris de glace est activé dans la politique de protection du département ; elle exige un motif consigné et avertit le département. Par défaut : administrateurs du département ; Tout le monde n'est volontairement pas proposé. Rapports Ces autorisations contrôlent le module Rapports, successeur des Journaux. Comme pour la plupart des autorisations Resgrid, une ligne non définie applique la valeur par défaut affichée, qui correspond au comportement actuel des Journaux. L'activation des Rapports pour votre service copie également vos réglages Créer un journal et Supprimer un journal sur les lignes correspondantes, sauf si vous les avez déjà définis ici. Les Rapports ne sont pas encore actifs pour ce service. Vous pouvez préparer ces réglages dès maintenant ; ils s'appliqueront une fois les Rapports activés. @@ -1012,4 +2184,190 @@ Définit qui peut transférer l’inventaire entre emplacements. Par défaut, cette autorisation est réservée aux administrateurs du département. + + Méthodes de second facteur + + + Choisissez les méthodes de vérification qui comptent comme authentification multifacteur dans ce département. Les codes d'application d'authentification (TOTP) sont toujours acceptés : désactiver une méthode ne bloque donc personne ; les membres qui l'utilisaient doivent vérifier à nouveau. + + + Seul le membre gestionnaire du département peut modifier ces paramètres. + + + Les clés d'accès ne sont pas encore disponibles sur ce système. Ces choix s'appliqueront dès qu'elles le seront. + + + Pas encore disponible + + + Accepter les clés d'accès pour la connexion et les vérifications de sécurité + + + Une clé d'accès enregistrée dans la même application compte comme MFA pour la connexion, le changement de département et les actions sensibles. + + + Accepter les clés d'accès pour les données protégées + + + Une clé d'accès compte comme MFA pour afficher et modifier les données protégées. Modifier ce réglage met fin à l'accès en cours aux données protégées ; les membres vérifient à nouveau. + + + Accepter l'approbation depuis l'application Responder + + + Là où les clés d'accès sont acceptées, un membre peut approuver une connexion ou une demande de données protégées avec la clé d'accès de son application Responder. Jamais utilisé pour les modifications de sécurité. Modifier ce réglage met fin à l'accès en cours aux données protégées. + + + Accepter la MFA de votre fournisseur d'identité pour la connexion et les vérifications de sécurité + + + Nécessite une correspondance MFA testée dans votre configuration SSO. + + + Accepter la MFA de votre fournisseur d'identité pour les données protégées + + + Nécessite une correspondance MFA testée dans votre configuration SSO. Modifier ce réglage met fin à l'accès en cours aux données protégées. + + + Votre configuration SSO n'a aucune correspondance MFA testée. Enregistrez une correspondance et terminez son test avant d'accepter la MFA de votre fournisseur d'identité. + + + Pour accepter la MFA de votre fournisseur d'identité, vérifiez d'abord avec votre application d'authentification ou une clé d'accès. La MFA du fournisseur d'identité ne peut pas approuver cette modification. + + + Permettre à une vérification récente à la connexion d'ouvrir les données protégées + + + Les membres qui ont validé la MFA à la connexion n'ont pas à vérifier de nouveau pour afficher les données protégées pendant la fenêtre de vérification. Modifier ce réglage met fin à l'accès en cours aux données protégées. + + + Permettre au déverrouillage d'un appareil partagé d'ouvrir les données protégées + + + Sur les tablettes de véhicule et les postes de travail partagés, la vérification récente de déverrouillage de l'opérateur compte pour afficher les données protégées. Modifier ce réglage met fin à l'accès en cours aux données protégées. + + + Appareils partagés de véhicule et de poste de travail + + + Les tablettes de véhicule et les postes de régulation partagés se verrouillent lorsque personne ne les utilise et se terminent avec la garde. Les opérateurs déverrouillent avec leur propre application d'authentification, clé d'accès ou approbation Responder, jamais avec un mot de passe conservé sur l'appareil. Une valeur plus stricte ici s'applique aussi aux sessions déjà en cours. + + + Seul le membre gestionnaire du département peut modifier la politique des appareils partagés. + + + Le mode appareil partagé n'est pas encore disponible sur cette installation. Ces valeurs sont conservées, et il ne peut être imposé à une autre application tant qu'il ne l'est pas. + + + Verrouiller après ce nombre de minutes d'inactivité + + + De 1 à {0} minutes. Seule l'activité de l'opérateur compte ; les mises à jour en arrière-plan et les alertes entrantes ne comptent pas. + + + Terminer la garde après ce nombre d'heures + + + De 1 à {0} heures après la connexion, quelle que soit l'activité. La garde suivante se reconnecte. + + + Toujours utiliser le mode partagé pour + + + Les sessions de ces applications dans ce département se verrouillent toujours en cas d'inactivité et se terminent avec la garde, quel que soit le réglage de l'installation. Les connexions qui n'indiquent pas leur application comptent aussi : les intégrations qui se connectent avec un mot de passe se verrouillent donc également. Utilisez des versions d'application compatibles avec le mode partagé. + + + Unit + + + IC (Commandement) + + + Dispatch + + + Choisissez un verrouillage d'inactivité de 1 à {0} minutes. + + + Choisissez une durée de garde de 1 à {0} heures. + + + Le mode appareil partagé n'est pas encore disponible sur cette installation ; il ne peut donc pas être imposé à une autre application. + + + MFA du fournisseur d'identité + + + Indiquez à Resgrid comment votre fournisseur d'identité signale qu'il a vérifié un membre avec la MFA. Là où ce département accepte la MFA de votre fournisseur d'identité, une connexion ou une vérification portant l'une de ces valeurs compte comme MFA, et les membres n'ont pas besoin d'un authentificateur Resgrid pour cela. Chaque modification doit réussir une connexion de test avant de prendre effet. + + + Configurez et activez d'abord une configuration SSO. + + + En vigueur : version {0}, testée le {1}. + + + Pas en vigueur : la version {0} n'a pas encore réussi son test. + + + Aucune correspondance n'est enregistrée. + + + Ce que Resgrid demande + + + Ce qui compte comme MFA + + + Une valeur par ligne. Les valeurs doivent correspondre exactement, casse comprise. Une réponse doit porter au moins une des valeurs. + + + acr_values à demander (OIDC) + + + Requête claims (OIDC, JSON) + + + Références de classe RequestedAuthnContext (SAML) + + + Valeurs amr (OIDC) + + + Valeurs acr (OIDC) + + + Valeurs acrs (OIDC, contexte d'authentification) + + + Valeurs AuthnContextClassRef (SAML) + + + Enregistrer la correspondance + + + Supprimer la correspondance + + + Correspondance enregistrée. Elle prend effet dès qu'elle réussit une connexion de test. + + + Correspondance supprimée. La MFA de votre fournisseur d'identité ne compte plus dans ce département. + + + La correspondance n'est pas utilisable : {0} + + + Modifier la correspondance nécessite une vérification récente avec votre application d'authentification ou une clé d'accès. La MFA de votre fournisseur d'identité ne peut pas approuver cette modification. + + + Correspondance MFA du fournisseur d'identité + + + Tester avec une connexion + + + Le test vous envoie maintenant vers votre fournisseur d'identité en demandant la MFA. Lorsqu'il renvoie une valeur que la correspondance compte, cette version prend effet. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx index 77341f294..923a4f079 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Sicurezza e autorizzazioni - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Qui puoi impostare le autorizzazioni del tuo dipartimento, ad esempio quali utenti o ruoli possono creare chiamate o chi è autorizzato ad aggiungere e rimuovere utenti. Le modifiche alle autorizzazioni avranno effetto al prossimo accesso all'applicazione web di Resgrid. - Audit Logs + Registri di controllo + + + Filtra per tipo di controllo + + + Tutti i tipi di controllo + + + Cerca per nome utente, ID utente o di controllo, indirizzo email, data/ora o tipo di controllo. La ricerca e l'ordinamento si applicano all'interno del tipo di controllo selezionato. + + + Cerca nei registri di controllo: + + + Nome, ID, email, data/ora o tipo + + + Data e ora + + + Tipo + + + Registrato da + + + Risultato + + + Messaggio + + + Termini di ricerca + + + Azioni + + + Riuscito + + + Non riuscito + + + Visualizza + + + Visualizzazione da _START_ a _END_ di _TOTAL_ voci + + + Visualizzazione da 0 a 0 di 0 voci + + + (filtrate da _MAX_ voci totali) + + + Mostra _MENU_ voci + + + Caricamento… + + + Nessuna voce nei registri di controllo + + + Nessuna voce corrispondente nei registri di controllo + + + Inizio + + + Fine + + + Successivo + + + Precedente + + + : attiva per ordinare la colonna in ordine crescente + + + : attiva per ordinare la colonna in ordine decrescente + + + Sistema + + + Sconosciuto + + + Visualizza registro di controllo + + + Registro di controllo + + + ID registro di controllo: + + + ID dipartimento: + + + Tipo di controllo: + + + ID tipo di registro: + + + Descrizione del tipo: + + + Risultato: + + + Registrato da: + + + ID utente: + + + Registrato il (ora locale): + + + Registrato il (UTC): + + + Indirizzo IP: + + + Nome del server: + + + ID oggetto: + + + ID dipartimento dell'oggetto: + + + User agent: + + + Messaggio: + + + Dati: + + + Non registrato + + + Impostazioni del dipartimento modificate + + + Utente aggiunto + + + Utente rimosso + + + Gruppo aggiunto + + + Gruppo rimosso + + + Gruppo modificato + + + Unità aggiunta + + + Unità rimossa + + + Unità modificata + + + Profilo aggiornato + + + Autorizzazioni modificate + + + Abbonamento aggiornato + + + Abbonamento creato + + + Abbonamento annullato + + + Dati di fatturazione dell'abbonamento aggiornati + + + Chiamata riattivata + + + Account utente eliminato + + + Abbonamento al componente aggiuntivo modificato + + + Eliminazione del dipartimento richiesta + + + Richiesta di eliminazione del dipartimento annullata + + + Turno statico eliminato + + + Turno statico aggiornato + + + Stato personalizzato aggiunto + + + Stato personalizzato rimosso + + + Stato personalizzato aggiornato + + + Dettaglio dello stato personalizzato aggiornato + + + Tipo di chiamata aggiunto + + + Tipo di chiamata modificato + + + Tipo di chiamata rimosso + + + Priorità di chiamata aggiunta + + + Priorità di chiamata modificata + + + Priorità di chiamata rimossa + + + Tipo di unità aggiunto + + + Tipo di unità modificato + + + Tipo di unità rimosso + + + Tipo di certificazione aggiunto + + + Tipo di certificazione modificato + + + Tipo di certificazione rimosso + + + Categoria di documenti aggiunta + + + Categoria di documenti modificata + + + Categoria di documenti rimossa + + + Documento aggiunto + + + Documento modificato + + + Documento rimosso + + + Categoria di note aggiunta + + + Categoria di note modificata + + + Categoria di note rimossa + + + Nota aggiunta + + + Nota modificata + + + Nota rimossa + + + Contatto aggiunto + + + Contatto modificato + + + Contatto rimosso + + + Categoria di contatto aggiunta + + + Categoria di contatto modificata + + + Categoria di contatto rimossa + + + Tipo di nota del contatto aggiunto + + + Tipo di nota del contatto modificato + + + Tipo di nota del contatto rimosso + + + Flusso di lavoro creato + + + Flusso di lavoro aggiornato + + + Flusso di lavoro eliminato + + + Passaggio del flusso di lavoro aggiunto + + + Passaggio del flusso di lavoro aggiornato + + + Passaggio del flusso di lavoro eliminato + + + Credenziale del flusso di lavoro aggiunta + + + Credenziale del flusso di lavoro aggiornata + + + Credenziale del flusso di lavoro eliminata + + + Codice di verifica del contatto inviato + + + Verifica del contatto confermata + + + Verifica del contatto non riuscita + + + 2FA abilitata + + + 2FA disabilitata + + + Accesso 2FA verificato + + + Codice di recupero 2FA usato + + + Controllo di sicurezza 2FA verificato + + + Configurazione SSO creata + + + Configurazione SSO aggiornata + + + Configurazione SSO eliminata + + + Accesso SSO riuscito + + + Accesso SSO non riuscito + + + Utente SSO sottoposto a provisioning + + + Utente SCIM creato + + + Utente SCIM aggiornato + + + Utente SCIM disattivato + + + Utente SCIM eliminato + + + Autenticazione SCIM non riuscita + + + Utente SCIM riattivato + + + Gruppi SCIM elencati + + + Utenti SCIM elencati + + + Utente SCIM recuperato + + + Bearer Token SCIM sottoposto a provisioning + + + Bearer Token SCIM ruotato + + + Definizione UDF creata + + + Definizione UDF aggiornata + + + Definizione UDF eliminata + + + Campo UDF aggiunto + + + Campo UDF aggiornato + + + Campo UDF rimosso + + + Valori dei campi UDF salvati + + + Percorso creato + + + Percorso aggiornato + + + Percorso eliminato + + + Percorso avviato + + + Percorso completato + + + Percorso annullato + + + Percorso messo in pausa + + + Percorso ripreso + + + Arrivo alla fermata registrato + + + Partenza dalla fermata registrata + + + Fermata del percorso saltata + + + Deviazione dal percorso rilevata + + + Deviazione dal percorso riconosciuta + + + Configurazione timer di registrazione creata + + + Configurazione timer di registrazione aggiornata + + + Configurazione timer di registrazione eliminata + + + Sovrascrittura timer di registrazione creata + + + Sovrascrittura timer di registrazione aggiornata + + + Sovrascrittura timer di registrazione eliminata + + + Registrazione effettuata + + + Timer di registrazione abilitato sulla chiamata + + + Timer di registrazione disabilitato sulla chiamata + + + Entrata all'evento del calendario registrata + + + Uscita dall'evento del calendario registrata + + + Orari di registrazione del calendario aggiornati + + + Registrazione entrata del calendario eliminata + + + Entrata nel calendario registrata da un amministratore + + + Log creato + + + Log eliminato + + + Test di comunicazione creato + + + Test di comunicazione aggiornato + + + Test di comunicazione eliminato + + + Esecuzione del test di comunicazione avviata + + + Fonte di allerta meteo creata + + + Fonte di allerta meteo aggiornata + + + Fonte di allerta meteo eliminata + + + Fonte di allerta meteo abilitata + + + Fonte di allerta meteo disabilitata + + + Zona di allerta meteo creata + + + Zona di allerta meteo aggiornata + + + Zona di allerta meteo eliminata + + + Zona di allerta meteo abilitata + + + Zona di allerta meteo disabilitata + + + Impostazioni delle allerte meteo modificate + + + Flag di funzionalità modificato + + + Sovrascrittura del flag di funzionalità modificata + + + Dispositivo di tracciamento dell'unità creato + + + Dispositivo di tracciamento dell'unità aggiornato + + + Dispositivo di tracciamento dell'unità disabilitato + + + Dispositivo di tracciamento dell'unità eliminato + + + Credenziale di tracciamento dell'unità creata + + + Credenziale di tracciamento dell'unità ruotata + + + Credenziale di tracciamento dell'unità revocata + + + Eliminazione del dipartimento eseguita + + + Messaggio della chat eliminato da un moderatore + + + Utente della chat silenziato + + + Utente della chat non più silenziato + + + Utente della chat bloccato + + + Utente della chat sbloccato + + + Canale della chat bloccato + + + Canale della chat sbloccato + + + Canale della chat archiviato + + + Segnalazione della chat risolta + + + Impostazioni della chat modificate + + + Esportazione della chat richiesta + + + Esportazione della chat scaricata + + + Segnalazione di moderazione inviata + + + Richiesta di moderazione riaperta + + + Richiesta di moderazione completata + + + Prova di moderazione scaricata + + + Password reimpostata da un amministratore + + + Sessioni di accesso dell'utente revocate + + + Esenzioni dalla verifica dei dati protetti modificate + + + Piano di pre-intervento del contatto aggiunto + + + Piano di pre-intervento del contatto aggiornato + + + Piano di pre-intervento del contatto rimosso + + + Allegato del contatto aggiunto + + + Allegato del contatto rimosso + + + Definizione della lista di controllo aggiunta + + + Definizione della lista di controllo aggiornata + + + Definizione della lista di controllo pubblicata + + + Definizione della lista di controllo ritirata + + + Definizione della lista di controllo rimossa + + + Completamento della lista di controllo avviato + + + Progressi della lista di controllo salvati + + + Completamento della lista di controllo inviato + + + Lista di controllo attestata dal testimone + + + File della lista di controllo aggiunto + + + File della lista di controllo rimosso + + + Pianificazione della lista di controllo aggiunta + + + Pianificazione della lista di controllo aggiornata + + + Controllo pianificato non eseguito + + + Controllo pianificato saltato + + + Impostazioni promemoria delle liste di controllo aggiornate + + + Ordine di lavoro modificato + + + Inventario modificato + + + Profilo di fatturazione modificato + + + Listino modificato + + + Fattura creata + + + Fattura aggiornata + + + Fattura inviata + + + Fattura annullata + + + Pagamento fattura registrato + + + Identità di fatturazione del dipartimento modificata + + + Account di pagamento collegato + + + Account di pagamento scollegato + + + Account di pagamento revocato + + + Account di pagamento: azione richiesta + + + Richiesta di pagamento della fattura creata + + + Pagamento fattura rimborsato + + + Pagamento fattura contestato + + + Webhook di pagamento respinto + + + Richiesta di pagamento della fattura non riuscita + + + Richiesta di pagamento della fattura scaduta + + + Certificazione aggiunta + + + Certificazione aggiornata + + + Certificazione rimossa + + + Stato certificazione modificato + + + Certificazione verificata + + + Crediti di certificazione aggiunti + + + Crediti di certificazione rimossi + + + Requisito di certificazione del ruolo modificato + + + Impostazioni di certificazione del dipartimento modificate + + + Membro del ruolo aggiunto + + + Membro del ruolo rimosso + + + Membro del ruolo rimosso per certificazione + + + Certificazione unità aggiunta + + + Certificazione unità aggiornata + + + Certificazione unità rimossa + + + Stato certificazione unità modificato + + + Impiego creato + + + Impiego aggiornato + + + Stato dell'impiego modificato + + + Organico dell'impiego modificato + + + Equipaggiamento dell'impiego modificato + + + Allegato dell'impiego aggiunto + + + Allegato dell'impiego rimosso + + + Rapporto ore creato + + + Rapporto ore aggiornato + + + Rapporto ore inviato + + + Rapporto ore approvato + + + Rapporto ore annullato + + + Spesa dell'impiego aggiunta + + + Spesa dell'impiego aggiornata + + + Spesa dell'impiego rimossa + + + Tabella tariffaria creata + + + Tabella tariffaria aggiornata + + + Tabella tariffaria eliminata + + + Voce della tabella tariffaria modificata + + + Maggiorazione tariffaria modificata + + + Contratto di servizio creato + + + Contratto di servizio aggiornato + + + Stato del contratto di servizio modificato + + + Contratto di servizio eliminato + + + Documento di conformità aggiunto + + + Documento di conformità aggiornato + + + Documento di conformità rimosso + + + Offerta creata + + + Offerta aggiornata + + + Offerta inviata + + + Offerta accettata + + + Offerta rifiutata + + + Offerta ritirata + + + Offerta scaduta + + + Offerta convertita + + + Offerta eliminata + + + Rapporto ore fatturato + + + Fattura dell'impiego generata + + + Profilo agenzia Cal OES MARS modificato + + + Profilo risorsa Cal OES MARS modificato + + + Profilo tariffe Cal OES MARS modificato + + + Bozza tariffe Cal OES MARS elaborata + + + Tariffa Cal OES MARS revisionata + + + Accordo Cal OES MARS modificato + + + Accordo Cal OES MARS osservato + + + Elemento Cal OES MARS preparato + + + Elemento Cal OES MARS validato + + + Rimborso Cal OES MARS calcolato + + + Elemento Cal OES MARS aperto per il passaggio + + + Stato esterno Cal OES MARS osservato + + + Fattura Cal OES MARS approvata + + + Fattura Cal OES MARS respinta + + + Pagamento Cal OES MARS riconciliato + + + Elemento Cal OES MARS eliminato + + + Profilo del datore di lavoro modificato + + + Sede di lavoro modificata + + + Rapporto di lavoro modificato + + + Retribuzione del personale modificata + + + Dati annuali di paga importati + + + Scheda demografica modificata + + + Rapporto dati retributivi creato + + + Rapporto dati retributivi validato + + + Rapporto dati retributivi congelato + + + Rapporto dati retributivi esportato + + + Rapporto dati retributivi contrassegnato come certificato + + + Rapporto dati retributivi corretto + + + Profilo di costo risorsa modificato + + + Utilizzo risorse modificato + + + Calcolo dei costi sul campo creato + + + Calcolo dei costi sul campo congelato + + + Utente riattivato + + + Configurazione del dipartimento modificata + + + Revisione di Admin Assist modificata + + + Accesso alla diagnostica di Admin Assist + + + Impostazioni dell'invio con IA aggiornate + + + Accesso al piano di Admin Assist + + + Criteri di sicurezza modificati - Permission + Autorizzazione Nota - Value + Valore Ruoli - Two-Factor Authentication (2FA) Enforcement + Obbligo di autenticazione a due fattori (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Richiedi la 2FA tramite app di autenticazione per gli utenti amministratori. Se attivata, gli amministratori che non hanno configurato la 2FA verranno reindirizzati alla registrazione prima di accedere alle funzioni amministrative. - Require 2FA for Admins + Richiedi la 2FA per gli amministratori - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Stabilisce quali utenti con livello di amministratore devono registrarsi alla 2FA tramite app di autenticazione (Google Authenticator, Microsoft Authenticator, Authy, ecc.). - Disabled (no requirement) + Disattivato (nessun obbligo) - Department Admins + Managing User + Amministratori del dipartimento + utente responsabile - Department Admins + Managing User + Group Admins + Amministratori del dipartimento + utente responsabile + amministratori di gruppo - 2FA enforcement setting saved. + Impostazione dell'obbligo 2FA salvata. - Failed to save 2FA enforcement setting. + Impossibile salvare l'impostazione dell'obbligo 2FA. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Impossibile attivare l'obbligo 2FA: l'utente responsabile di questo dipartimento non ha attivato l'autenticazione a due fattori sul proprio account. L'utente responsabile deve attivare la 2FA prima che questa impostazione possa essere abilitata. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Impossibile attivare l'obbligo 2FA: non hai attivato l'autenticazione a due fattori sul tuo account. Devi attivare la 2FA prima di poterla richiedere agli altri. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Impossibile attivare l'obbligo 2FA: l'autenticazione a due fattori non è attiva né sul tuo account né su quello dell'utente responsabile. La 2FA va attivata su entrambi gli account prima che questa impostazione possa essere abilitata. - This setting is locked until the above conditions are met. + Questa impostazione è bloccata finché non vengono soddisfatte le condizioni indicate sopra. - Permission + Autorizzazione Nota - Selection + Selezione - Group Only + Solo gruppo Ruoli - No Roles + Nessun ruolo - N/A + N/D + + + Tutti + + + Amministratori del dipartimento + + + Amministratori del dipartimento e dei gruppi + + + Amministratori del dipartimento e ruoli selezionati + + + Amministratori del dipartimento e dei gruppi, più ruoli selezionati - Security and Permissions + Sicurezza e autorizzazioni + + + Home - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Qui puoi impostare le autorizzazioni del tuo dipartimento, ad esempio quali utenti o ruoli possono creare chiamate o chi è autorizzato ad aggiungere e rimuovere utenti. Le modifiche alle autorizzazioni avranno effetto al prossimo accesso all'applicazione web di Resgrid. - Who can Add Users + Chi può aggiungere utenti - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Questa opzione stabilisce chi può aggiungere utenti/personale al dipartimento. Per impostazione predefinita solo gli amministratori del dipartimento (e il membro responsabile) possono aggiungere utenti. È però possibile consentirlo anche agli amministratori di gruppo (limitatamente al gruppo di cui sono amministratori). - Who can Remove Users + Chi può rimuovere utenti - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Questa opzione stabilisce chi può rimuovere utenti/personale dal dipartimento. Per impostazione predefinita solo gli amministratori del dipartimento (e il membro responsabile) possono rimuovere utenti. È però possibile consentirlo anche agli amministratori di gruppo (limitatamente agli utenti del gruppo di cui sono amministratori). - Who can Create Calls + Chi può creare chiamate - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Questa opzione stabilisce chi può creare manualmente chiamate nel sistema Resgrid. Per impostazione predefinita tutti possono creare chiamate. - Who can Delete Calls + Chi può eliminare chiamate - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Questa opzione stabilisce chi può eliminare chiamate dal sistema Resgrid. Per impostazione predefinita tutti possono eliminare chiamate. - Who can Close Calls + Chi può chiudere chiamate - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Questa opzione stabilisce chi può chiudere chiamate nel sistema Resgrid. Per impostazione predefinita tutti possono chiudere chiamate. - Who can Add Data To Calls + Chi può aggiungere dati alle chiamate - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Questa opzione stabilisce chi può aggiungere dati, come immagini, note e file, alle chiamate nel sistema Resgrid. Per impostazione predefinita tutti possono aggiungere dati alle chiamate. - Who can Create Trainings + Chi può creare formazioni - This option determines who can create trainings. By default only Department Admins can create trainings. + Questa opzione stabilisce chi può creare formazioni. Per impostazione predefinita solo gli amministratori del dipartimento possono creare formazioni. - Who can Add Documents + Chi può aggiungere documenti - This option determines who can add documents. By default Everyone can add documents. + Questa opzione stabilisce chi può aggiungere documenti. Per impostazione predefinita tutti possono aggiungere documenti. - Who can Create Calendar Entries + Chi può creare voci di calendario - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Questa opzione stabilisce chi può creare voci di calendario. Per impostazione predefinita tutti possono creare voci di calendario. - Who can Create Notes + Chi può creare note - This option determines who can create notes. By default Everyone can create notes. + Questa opzione stabilisce chi può creare note. Per impostazione predefinita tutti possono creare note. - Who can Add Log Entries + Chi può aggiungere voci di log - This option determines who can add log entries. By default Everyone can add log entries. + Questa opzione stabilisce chi può aggiungere voci di log. Per impostazione predefinita tutti possono aggiungere voci di log. - Who can Create Shifts + Chi può creare turni - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Questa opzione stabilisce chi può creare e modificare turni. Per impostazione predefinita solo gli amministratori del dipartimento possono creare e modificare turni. - Who can View Personal Info + Chi può visualizzare le informazioni personali - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Questa opzione stabilisce chi può visualizzare le informazioni personali (PII) del personale nel sistema, ad esempio indirizzo email, numeri di telefono, ecc. Per impostazione predefinita tutti possono visualizzare queste informazioni. - Who can Adjust Inventory + Chi può rettificare l'inventario - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Questa opzione stabilisce chi può rettificare le giacenze di inventario nel sistema. Per impostazione predefinita tutti possono rettificare l'inventario. - Who can see the Location of Personnel + Chi può vedere la posizione del personale - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Questa opzione stabilisce chi può vedere la posizione del personale sulle mappe. Per limitare l'opzione ai soli amministratori di gruppo e ai ruoli all'interno di un gruppo, seleziona l'opzione Solo gruppo. - Who can see the Location of Units + Chi può vedere la posizione delle unità - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Questa opzione stabilisce chi può vedere la posizione delle unità sulle mappe. Per limitare l'opzione ai soli amministratori di gruppo e ai ruoli all'interno di un gruppo, seleziona l'opzione Solo gruppo. - Who can send messages + Chi può inviare messaggi - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Questa opzione stabilisce chi può creare e inviare messaggi (posta interna al sistema). Per impostazione predefinita tutti possono creare e inviare messaggi. - Who can view users + Chi può visualizzare gli utenti - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + Per impostazione predefinita tutti gli utenti possono vedere tutti gli altri utenti del sistema. Questa opzione ti consente di limitare chi può vedere gli utenti nel sistema. - Who can view units + Chi può visualizzare le unità - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + Per impostazione predefinita tutti gli utenti possono vedere tutte le unità del sistema. Questa opzione ti consente di limitare chi può visualizzare le unità nel sistema. - Who can view Contacts + Chi può visualizzare i contatti - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + Per impostazione predefinita tutti gli utenti possono vedere tutti i contatti del sistema. Questa opzione ti consente di limitare chi può visualizzare i contatti nel sistema. Un utente che non può visualizzare i contatti non potrà nemmeno aggiungerli a una chiamata. - Who can edit or create Contacts + Chi può modificare o creare contatti - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + Per impostazione predefinita tutti gli utenti possono creare e modificare i contatti del sistema. Questa opzione ti consente di limitare chi può creare o modificare i contatti nel sistema. - Who can delete Contacts + Chi può eliminare contatti - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + Per impostazione predefinita tutti gli utenti possono eliminare i contatti del sistema. Questa opzione ti consente di limitare chi può eliminare i contatti nel sistema. - Who can Create/Edit Workflows + Chi può creare/modificare flussi di lavoro - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Questa opzione stabilisce chi può creare, modificare ed eliminare flussi di lavoro e i relativi passaggi. Per impostazione predefinita solo gli amministratori del dipartimento possono gestire i flussi di lavoro. - Who can Manage Workflow Credentials + Chi può gestire le credenziali dei flussi di lavoro - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Questa opzione stabilisce chi può creare, modificare ed eliminare le credenziali crittografate usate dalle azioni dei flussi di lavoro (ad es. password SMTP, chiavi API). Per impostazione predefinita solo gli amministratori del dipartimento possono gestire le credenziali. - Who can View Workflow Runs + Chi può visualizzare le esecuzioni dei flussi di lavoro - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Questa opzione stabilisce chi può visualizzare la cronologia di esecuzione dei flussi di lavoro, i log delle esecuzioni e le dashboard di stato. Per impostazione predefinita solo gli amministratori del dipartimento possono visualizzare le esecuzioni dei flussi di lavoro. - Single Sign-On (SSO) & SCIM + Single Sign-On (SSO) e SCIM - Single Sign-On & Identity Provisioning + Single Sign-On e provisioning delle identità SSO / SCIM - Security Policy + Criteri di sicurezza - Add OIDC Config + Aggiungi configurazione OIDC - Add SAML 2.0 Config + Aggiungi configurazione SAML 2.0 - What is SSO & SCIM? + Cosa sono SSO e SCIM? Single Sign-On (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Consenti ai membri del dipartimento di accedere con la loro identità aziendale esistente (Microsoft Entra ID, Okta, Google Workspace, ecc.) invece che con una password Resgrid separata. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC — Protocollo moderno, ideale per Entra, Okta, Auth0, Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 — Ampiamente supportato dagli IdP governativi e aziendali - SCIM 2.0 Provisioning + Provisioning SCIM 2.0 - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Sincronizza automaticamente gli utenti dal tuo provider di identità. Quando inserisci o rimuovi personale nella tua directory aziendale, Resgrid recepisce automaticamente queste modifiche, senza passaggi manuali di invito o rimozione. - Auto-create new members when added in your IdP + Crea automaticamente i nuovi membri quando vengono aggiunti nel tuo IdP - Disable/remove members when deprovisioned + Disattiva/rimuovi i membri al momento del deprovisioning - Keep names & email addresses in sync + Mantieni sincronizzati nomi e indirizzi email - Security Policy + Criteri di sicurezza - Enforce department-wide compliance controls alongside SSO: + Applica controlli di conformità a livello di dipartimento insieme all'SSO: - Mandate MFA for all members + Rendi obbligatoria l'MFA per tutti i membri - Restrict login to SSO only (disable passwords) + Limita l'accesso al solo SSO (disattiva le password) - Limit logins to specific IP CIDR ranges + Limita gli accessi a specifici intervalli IP CIDR - Set password expiration & complexity rules + Imposta regole di scadenza e complessità delle password - Classify data level (Unclassified / CUI / Confidential) + Classifica il livello dei dati (Non classificato / CUI / Riservato) - Mobile App Discovery URL + URL di rilevamento per l'app mobile - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + L'app mobile Resgrid usa questo URL per rilevare le tue impostazioni SSO prima di mostrare la schermata di accesso. Se necessario, condividilo con il tuo team mobile. - Copy + Copia - Copied to clipboard! + Copiato negli appunti! - SSO Configurations + Configurazioni SSO - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Nessuna configurazione SSO presente. Usa i pulsanti qui sopra per aggiungere OIDC o SAML 2.0. Provider - Identifier / Endpoint + Identificatore / endpoint Stato - Local Login + Accesso locale - Auto-Provision + Provisioning automatico SCIM - Created + Creata il Azioni - Enabled + Abilitata - Disabled + Disabilitata Sì - SSO Only + Solo SSO - On + Attivo - Off + Disattivato Attivo - No Token + Nessun token - Off + Disattivato Modifica @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Conferma eliminazione - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Vuoi davvero eliminare la configurazione SSO {0}? L'operazione non può essere annullata. + + + Tipo di provider non valido. + + + Esiste già una configurazione SSO per {0}. Usa Modifica per cambiarla. + + + Il Client ID OIDC è obbligatorio. + + + L'autorità OIDC deve essere un URL HTTPS valido. + + + Per convalidare le asserzioni SAML è necessario un certificato di firma dell'IdP. + + + L'URL di single sign-on dell'IdP deve essere un URL HTTPS valido. + + + Configurazione SSO {0} creata correttamente. + + + Configurazione SSO aggiornata correttamente. + + + Configurazione SSO {0} eliminata. Annulla @@ -453,10 +1569,10 @@ Elimina - New SSO Configuration + Nuova configurazione SSO - Edit SSO Configuration + Modifica configurazione SSO Nuovo @@ -465,61 +1581,70 @@ Modifica - Step 1 + Passaggio 1 - Provider & Basic Settings + Provider e impostazioni di base - Identity Provider Protocol + Protocollo del provider di identità + + + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + + + SAML 2.0 — La maggior parte degli IdP aziendali e governativi - Enable this SSO configuration + Abilita questa configurazione SSO - Only one configuration per provider type is active at a time. + Per ogni tipo di provider è attiva una sola configurazione alla volta. - Allow local password login + Consenti l'accesso locale con password - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + Se l'opzione è selezionata, gli utenti possono continuare ad accedere con nome utente e password oltre che con l'SSO. Disattivala insieme all'opzione Richiedi SSO dei Criteri di sicurezza per imporre l'accesso solo tramite SSO. - Auto-provision new users + Provisioning automatico dei nuovi utenti - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Crea automaticamente un account Resgrid quando un utente si autentica tramite SSO per la prima volta e non viene trovata alcuna email corrispondente. Lascia disattivato per richiedere prima un invito manuale. - Enable SCIM 2.0 provisioning + Abilita il provisioning SCIM 2.0 - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Consente al tuo IdP di creare/aggiornare/disattivare automaticamente i membri. Dopo il salvataggio, vai alla pagina Configurazione SCIM per generare un Bearer Token. - Default Rank for Auto-Provisioned Users + Grado predefinito per gli utenti con provisioning automatico + + + (Nessun grado predefinito) - Optional. Applied only when Auto-Provision creates a new member. + Facoltativo. Applicato solo quando il provisioning automatico crea un nuovo membro. - Step 2 — OIDC + Passaggio 2 — OIDC - OpenID Connect Settings + Impostazioni OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Dove trovare questi valori: nel tuo IdP, registra Resgrid come client pubblico (PKCE, nessun segreto client richiesto per il mobile) o come app web (con segreto per i flussi lato server). Copia il Client ID e l'URL dell'emittente dall'applicazione registrata. - Authority / Issuer URL + URL dell'autorità / emittente https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Esempi: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com Client ID @@ -528,163 +1653,184 @@ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + L'identificatore client pubblico assegnato dal tuo IdP. Può essere mostrato senza rischi: non è un segreto. - Client Secret + Segreto client - Secret stored + Segreto archiviato - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Crittografato a riposo con la chiave specifica del tuo dipartimento. Lascia vuoto se usi PKCE (mobile/SPA: nessun segreto necessario). - Leave blank to keep the existing secret unchanged. + Lascia vuoto per mantenere invariato il segreto esistente. - Step 2 — SAML 2.0 + Passaggio 2 — SAML 2.0 - SAML 2.0 Settings + Impostazioni SAML 2.0 - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Dove trovare questi valori: nel tuo IdP, crea una nuova applicazione SAML. Inserisci nel tuo IdP l'SP Entity ID e l'URL ACS mostrati di seguito, quindi incolla qui l'URL dei metadati dell'IdP o il certificato. - SP Entity ID (enter this into your IdP) + SP Entity ID (inseriscilo nel tuo IdP) - ACS URL (Assertion Consumer Service — enter this into your IdP) + URL ACS (Assertion Consumer Service — inseriscilo nel tuo IdP) - This URL uses an encrypted department token — never exposes your plain department code. + Questo URL usa un token del dipartimento crittografato e non espone mai il codice del dipartimento in chiaro. - SP Entity ID (editable) + SP Entity ID (modificabile) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Deve corrispondere esattamente a quanto inserito nell'IdP. - IdP Metadata URL + URL dei metadati dell'IdP https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid recupererà periodicamente questo URL per mantenere aggiornati i certificati dell'IdP. - ACS URL (stored) + URL ACS (archiviato) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Compilato automaticamente dall'URL generato sopra. Modificalo solo se hai configurato un URL ACS diverso nel tuo IdP. + + + URI di reindirizzamento per l'accesso avviato da Resgrid (inseriscilo nel tuo provider di identità) + + + Aggiungilo accanto agli URI di reindirizzamento già registrati; le versioni precedenti delle app Resgrid continuano a usare i loro. + + + URI di reindirizzamento per l'accesso nelle app Resgrid (inseriscili tutti nel tuo provider di identità) + + + Ogni app, e l'edizione web di ciascuna, torna al proprio indirizzo, quindi registrali tutti. Li usano le versioni precedenti delle app e le app il cui accesso non viene avviato da Resgrid. + + + {0} (web) + + + URL di single sign-on del provider di identità + + + L'indirizzo di accesso SAML del provider di identità (binding HTTP-Redirect). Serve per gli accessi avviati dalle app e dal sito web di Resgrid. - IdP Signing Certificate (PEM) + Certificato di firma dell'IdP (PEM) - Certificate stored + Certificato archiviato - Encrypted at rest. Leave blank to keep the existing certificate. + Crittografato a riposo. Lascia vuoto per mantenere il certificato esistente. - SP Signing Key (PEM, optional) + Chiave di firma SP (PEM, facoltativa) - Key stored + Chiave archiviata - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Crittografata a riposo. Lascia vuoto se il tuo IdP non richiede richieste firmate. - Step 3 + Passaggio 3 - Attribute Mapping + Mappatura degli attributi - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Associa i nomi dei claim inviati dal tuo IdP ai campi utente di Resgrid. Lascia vuoto per usare automaticamente gli URI dei claim standard. Il valore è il nome del claim così come viene emesso dal tuo IdP. - Attribute Mapping JSON + JSON di mappatura degli attributi - Must be valid JSON. Use the quick-fill buttons on the right. + Deve essere un JSON valido. Usa i pulsanti di compilazione rapida a destra. - Quick-fill presets + Preimpostazioni di compilazione rapida - Click to auto-fill the mapping JSON with known defaults for your IdP. + Fai clic per compilare automaticamente il JSON di mappatura con i valori predefiniti noti per il tuo IdP. - Supported Resgrid field keys + Chiavi dei campi Resgrid supportate - Key + Chiave Descrizione - User's email address + Indirizzo email dell'utente - Given / first name + Nome proprio / nome - Surname / family name + Cognome / nome di famiglia - Unique IdP subject (for account linking) + Subject univoco dell'IdP (per il collegamento degli account) - Create Configuration + Crea configurazione - Save Changes + Salva modifiche Annulla - SCIM 2.0 Setup + Configurazione SCIM 2.0 - SCIM 2.0 Provisioning Setup + Configurazione del provisioning SCIM 2.0 - SCIM Setup + Configurazione SCIM - Back to SSO + Torna a SSO - Important — Copy your SCIM Bearer Token Now + Importante — Copia subito il tuo Bearer Token SCIM - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Questo token viene mostrato una sola volta e non può essere recuperato. Copialo subito e incollalo nella configurazione SCIM del tuo provider di identità. Se lo perdi, fai clic su Ruota token per generarne uno nuovo. - Copy Token + Copia token - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Questo token è archiviato crittografato nel database Resgrid con la chiave di crittografia specifica del tuo dipartimento. - SCIM Status + Stato SCIM Provider - SCIM Enabled + SCIM abilitato Sì @@ -696,58 +1842,58 @@ Bearer Token - Configured + Configurato - Not Set + Non impostato - Rotate SCIM Token + Ruota token SCIM - Generate SCIM Token + Genera token SCIM - Rotating generates a new token and invalidates the old one. + La rotazione genera un nuovo token e invalida quello precedente. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Sei sicuro? Qualsiasi integrazione SCIM esistente smetterà di funzionare finché non aggiorni il token nel tuo IdP. - SCIM Connector Settings + Impostazioni del connettore SCIM - Enter these values into your identity provider's SCIM provisioning configuration. + Inserisci questi valori nella configurazione del provisioning SCIM del tuo provider di identità. - Setting + Impostazione - Value + Valore - SCIM Connector Base URL + URL base del connettore SCIM - Authentication Method + Metodo di autenticazione - Authorization Header + Intestazione di autorizzazione - Custom Header Name + Nome dell'intestazione personalizzata - Custom Header Value + Valore dell'intestazione personalizzata - Supported Resources + Risorse supportate - Supported Update Method + Metodo di aggiornamento supportato - Step-by-Step Setup Guide + Guida alla configurazione passo passo Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Altro / generico - Any SCIM 2.0 compatible client can be configured with the following: + Qualsiasi client compatibile con SCIM 2.0 può essere configurato con i seguenti valori: - Resgrid SCIM field mapping + Mappatura dei campi SCIM di Resgrid - Parameter + Parametro - SCIM field + Campo SCIM - Resgrid effect + Effetto in Resgrid @@ -814,112 +1960,136 @@ Per istruzioni dettagliate specifiche per IdP (Okta, Microsoft Entra ID, Google Workspace) e la gestione dei token, visita la pagina di configurazione SCIM per ogni configurazione. - Department Security Policy + Criteri di sicurezza del dipartimento - Department Security Policy + Criteri di sicurezza del dipartimento - Security Policy + Criteri di sicurezza - Back to SSO + Torna a SSO - Security policy saved successfully. + Criteri di sicurezza salvati correttamente. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Nessuna configurazione SSO attiva. Puoi configurare i criteri delle password qui sotto, ma Richiedi SSO non può essere attivato finché non hai una configurazione SSO attiva. - Configure SSO → + Configura SSO → - Authentication Controls + Controlli di autenticazione - Require MFA for all members + Richiedi l'MFA per tutti i membri - Members who have not enrolled in MFA will be prompted to do so on next login. + Ai membri che non si sono registrati all'MFA verrà chiesto di farlo al prossimo accesso. - Require SSO — disable password login + Richiedi SSO — disattiva l'accesso con password - Requires active SSO config + Richiede una configurazione SSO attiva - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Attenzione: attivando questa opzione vengono bloccati tutti gli accessi con nome utente/password. Prima di attivarla, assicurati che almeno un amministratore abbia testato con successo l'accesso SSO. - Session Timeout (minutes) + Timeout della sessione (minuti) - 0 = use system default. 480 = 8 hours. + 0 = usa il valore predefinito di sistema. 480 = 8 ore. - Max Concurrent Sessions per User + Numero massimo di sessioni simultanee per utente - 0 = unlimited. Government environments typically set 1. + 0 = illimitate. Gli ambienti governativi in genere impostano 1. - Allowed IP Ranges (CIDR) + Intervalli IP consentiti (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Un blocco CIDR per riga, oppure separati da virgole. Vuoto = consenti tutti. Gli accessi provenienti da fuori questi intervalli verranno negati. - Data Classification Level + Livello di classificazione dei dati + + + Non classificato + + + CUI - Informazioni non classificate controllate + + + Riservato - Used for compliance reporting and audit logs. + Usato per i report di conformità e i registri di controllo. - Password Policy + Criteri delle password - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + I criteri delle password si applicano solo agli accessi locali (non SSO). Se attivi Richiedi SSO qui sopra, queste impostazioni non hanno effetto. - Password Expiration (days) + Scadenza della password (giorni) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = le password non scadono mai. 90 è il valore tipico per gli ambienti CUI. - Minimum Password Length + Lunghezza minima della password - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Minimo 8 (valore predefinito di sistema). I criteri del dipartimento possono solo aumentarlo. NIST consiglia almeno 12 caratteri; CUI ne richiede almeno 14. + + + Complessità della password imposta dal sistema + + + Tutti gli account Resgrid devono rispettare il seguente standard di complessità della password. Non può essere disattivato dai criteri del dipartimento. + + + Almeno 8 caratteri (o la lunghezza minima del dipartimento indicata sopra) + + + Almeno una cifra (numero) + + + Almeno una lettera maiuscola e una minuscola - Require password complexity + Richiedi la complessità della password - Enforces at least one uppercase letter, one digit, and one special character. + Impone almeno una lettera maiuscola, una cifra e un carattere speciale. - Quick presets + Preimpostazioni rapide - Government / CUI + Governativo / CUI - Standard Enterprise + Aziendale standard - Minimal + Minimo - Save Security Policy + Salva criteri di sicurezza Annulla - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Impossibile attivare l'accesso solo tramite SSO: non esiste alcuna configurazione SSO attiva. Crea e abilita prima una configurazione SSO. La password non può essere vuota. La password deve contenere almeno una cifra (numero). @@ -927,71 +2097,73 @@ La password deve contenere almeno una lettera minuscola. La password deve essere lunga almeno {0} caratteri. La lunghezza minima della password non può essere inferiore al valore predefinito di sistema di 8 caratteri. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. - Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. - Rapporti - Queste autorizzazioni controllano il modulo Rapporti, successore dei Registri. Come per la maggior parte delle autorizzazioni Resgrid, una riga non impostata usa il valore predefinito mostrato, che corrisponde al comportamento attuale dei Registri. Attivando i Rapporti per il dipartimento vengono inoltre copiate le impostazioni Crea registro ed Elimina registro sulle righe corrispondenti, salvo che non le abbiate già impostate qui. - I Rapporti non sono ancora attivi per questo dipartimento. Potete preparare queste impostazioni adesso; avranno effetto quando i Rapporti verranno attivati. - Creare rapporti - Chi può redigere nuovi rapporti e modificare le proprie bozze. Corrisponde a Crea registro finché non lo modificate. - Annullare rapporti - Chi può annullare una bozza o invalidare un rapporto finalizzato. L'invalidazione conserva il rapporto e la sua cronologia; nulla viene eliminato. Corrisponde a Elimina registro finché non lo modificate. - Revisionare rapporti - Chi può revisionare i rapporti inviati in revisione e restituirli per correzione. Solo gruppo limita gli amministratori di gruppo ai rapporti del proprio gruppo. - Approvare rapporti - Chi può approvare un rapporto revisionato prima della finalizzazione, per i tipi di rapporto che usano il ciclo di approvazione. - Finalizzare rapporti - Chi può finalizzare un rapporto, assegnandone il numero e bloccandone il contenuto. I tipi a inserimento rapido vengono finalizzati al salvataggio, quindi corrisponde a Crea registro finché non lo modificate. - Emendare rapporti finalizzati - Chi può aprire un emendamento su un rapporto finalizzato. L'emendamento crea una nuova revisione; la revisione originale resta intatta. - Trasmettere rapporti all'esterno - Chi può trasmettere un rapporto finalizzato a una destinazione di segnalazione esterna, come un sistema regionale o nazionale. - Stampare ed esportare rapporti - Chi può stampare un rapporto o esportare un elenco di rapporti. - Condividere rapporti - Chi può condividere un rapporto con altri gruppi del dipartimento o con persone esterne. + Eliminare voci di log + Chi nel tuo dipartimento può eliminare le voci di log + Usare la sincronizzazione del calendario + Definisce chi può attivare e usare gli URL di sottoscrizione del calendario per sincronizzare gli eventi del calendario Resgrid con applicazioni di calendario esterne. + Accesso all'app Dispatch + Definisce chi può accedere all'app Dispatch. Dispatch mostra le comunicazioni private di comando, delle unità e dei soccorritori per ogni incidente, quindi limita questa autorizzazione se non tutti i tuoi membri sono operatori di centrale. + Accesso all'app Command + Definisce chi può agire come comandante: accedere all'app IC, stabilire il comando dell'incidente su una chiamata e visualizzare le lavagne di comando. Se restringi questa impostazione rispetto a Tutti, le persone che scegli possono anche collaborare su qualsiasi lavagna di comando (assegnare e spostare risorse, gestire timer e accountability) senza ricoprire una posizione ICS su di essa: utile per consentire agli operatori di centrale di dare una mano dall'app Dispatch. Finché è impostata su Tutti, le azioni sulla lavagna restano limitate al comandante dell'incidente e ai ruoli ICS assegnati. + Protezione avanzata dei dati + Queste autorizzazioni stabiliscono chi può lavorare con i dati crittografati (protetti) quando il componente aggiuntivo Protezione avanzata dei dati è attivo. Ogni visualizzazione o modifica richiede inoltre una verifica a due fattori recente; queste impostazioni stabiliscono chi può anche solo tentarla. A differenza della maggior parte delle autorizzazioni Resgrid, i valori non impostati usano per impostazione predefinita la selezione restrittiva mostrata. + Gestire le impostazioni di protezione dei dati + Chi può modificare le impostazioni di Protezione avanzata dei dati, come la finestra di verifica e le opzioni sul contenuto delle notifiche. Acquisto, registrazione e disdetta restano sempre riservati al membro responsabile del dipartimento. + Visualizzare i dati protetti delle chiamate + Chi può mostrare i campi protetti delle chiamate (natura, indirizzo, dati di contatto, note) dopo la verifica a due fattori. Il valore predefinito è Tutti, perché il personale che interviene deve poter leggere un dispaccio. + Modificare i dati protetti delle chiamate + Chi può modificare i campi protetti delle chiamate dopo la verifica a due fattori. Il valore predefinito è Tutti, in linea con il normale flusso di lavoro delle chiamate. + Visualizzare i dati protetti del personale + Chi può mostrare le informazioni protette del personale (numeri di matricola, contatti di emergenza) dopo la verifica a due fattori. Il valore predefinito è Amministratori del dipartimento. + Visualizzare i dati protetti dei contatti + Chi può mostrare le informazioni protette dei contatti (nomi, numeri di telefono, documenti d'identità, posizioni) dopo la verifica a due fattori. Il valore predefinito è Amministratori del dipartimento. + Visualizzare i dati operativi protetti + Chi può mostrare i contenuti operativi protetti (log, invii di moduli, note e allegati del comando dell'incidente) dopo la verifica a due fattori. Il valore predefinito è Amministratori del dipartimento e del gruppo. + Esportare i dati protetti + Chi può esportare dati che contengono campi protetti. Le esportazioni escono dalla protezione di Resgrid, quindi ogni esportazione viene registrata separatamente per il controllo. Il valore predefinito è Amministratori del dipartimento; l'opzione Tutti volutamente non è disponibile. + Configurare l'invio dei dati protetti + Chi può modificare il modo in cui i contenuti protetti escono da Resgrid tramite notifiche push, SMS, email e chiamate vocali. Il valore predefinito è Amministratori del dipartimento; l'opzione Tutti volutamente non è disponibile. + Accesso di emergenza (break-glass) + Chi può usare il percorso di accesso di emergenza, soggetto a controllo, per i dati protetti. Funziona solo se l'accesso break-glass è abilitato nei criteri di protezione del dipartimento, richiede un motivo registrato e invia una notifica al dipartimento. Il valore predefinito è Amministratori del dipartimento; l'opzione Tutti volutamente non è disponibile. + Registri + Queste autorizzazioni controllano il modulo Registri, successore del modulo Log. Come per la maggior parte delle autorizzazioni Resgrid, una riga non impostata usa il valore predefinito mostrato, che corrisponde al comportamento attuale dei Log. Attivando i Registri per il dipartimento vengono inoltre copiate le impostazioni Crea log ed Elimina log sulle righe corrispondenti, salvo che tu non le abbia già impostate qui. + I Registri non sono ancora attivi per questo dipartimento. Puoi preparare queste impostazioni adesso; avranno effetto quando i Registri verranno attivati. + Creare registri + Chi può redigere nuovi registri e modificare le proprie bozze. Corrisponde a Crea log finché non lo modifichi. + Annullare registri + Chi può annullare una bozza o invalidare un registro finalizzato. L'invalidazione conserva il registro e la sua cronologia; nulla viene eliminato. Corrisponde a Elimina log finché non lo modifichi. + Revisionare registri + Chi può revisionare i registri inviati in revisione e restituirli per correzione. Solo gruppo limita gli amministratori di gruppo ai registri del proprio gruppo. + Approvare registri + Chi può approvare un registro revisionato prima della finalizzazione, per i tipi di registro che usano il ciclo di approvazione. + Finalizzare registri + Chi può finalizzare un registro, assegnandone il numero e bloccandone il contenuto. I tipi a inserimento rapido vengono finalizzati al salvataggio, quindi corrisponde a Crea log finché non lo modifichi. + Emendare registri finalizzati + Chi può aprire un emendamento su un registro finalizzato. L'emendamento crea una nuova revisione; la revisione originale resta intatta. + Trasmettere registri all'esterno + Chi può trasmettere un registro finalizzato a una destinazione di segnalazione esterna, come un sistema regionale o nazionale. + Stampare ed esportare registri + Chi può stampare un registro o esportare un elenco di registri. + Condividere registri + Chi può condividere un registro con altri gruppi del dipartimento o con persone esterne. Visualizzare sezioni riservate - Chi può leggere le sezioni riservate di un rapporto, come i dettagli di un rapporto del medico legale. Gli altri vedono che la sezione esiste, ma non il suo contenuto. - Visualizzare registri precedenti - Chi può leggere la cronologia dei Registri in sola lettura precedente all'attivazione dei Rapporti. - Visualizzare rapporti di altri gruppi - Se il personale può vedere i rapporti di altri gruppi. Spuntate Solo gruppo e impostate la visibilità per gruppo nelle Impostazioni rapporti per limitare ciascuno ai rapporti del proprio gruppo. Lasciate senza restrizioni per mantenere la vista a livello di dipartimento che i Registri hanno oggi. - Gestire definizioni di rapporto - Chi può creare e modificare le definizioni di rapporto, i moduli da cui sono costruiti i rapporti. - Pubblicare definizioni di rapporto - Chi può pubblicare una versione di definizione di rapporto affinché sia utilizzabile per nuovi rapporti. - Gestire report sui rapporti - Chi può creare e modificare i report salvati sui rapporti e le loro pianificazioni. - Gestire divulgazioni di rapporti + Chi può leggere le sezioni riservate di un registro, come i dettagli di un registro del medico legale. Gli altri vedono che la sezione esiste, ma non il suo contenuto. + Visualizzare log precedenti + Chi può leggere la cronologia dei Log in sola lettura precedente all'attivazione dei Registri. + Visualizzare registri di altri gruppi + Se il personale può vedere i registri di altri gruppi. Spunta Solo gruppo e imposta la visibilità per gruppo nelle Impostazioni registri per limitare ciascuno ai registri del proprio gruppo. Lascia senza restrizioni per mantenere la vista a livello di dipartimento che i Log hanno oggi. + Gestire definizioni di registro + Chi può creare e modificare le definizioni di registro, i moduli da cui sono costruiti i registri. + Pubblicare definizioni di registro + Chi può pubblicare una versione di definizione di registro affinché sia utilizzabile per nuovi registri. + Gestire report sui registri + Chi può creare e modificare i report salvati sui registri e le loro pianificazioni. + Gestire divulgazioni di registri Chi può preparare e rilasciare i pacchetti di divulgazione di atti pubblici. Gestire vincoli legali - Chi può applicare e rimuovere i vincoli legali, che sospendono la conservazione e impediscono l'invalidazione di un rapporto. - Riassegnare bozze di rapporto - Chi può trasferire una bozza di rapporto a un altro autore, ad esempio quando l'autore originale non è disponibile. + Chi può applicare e rimuovere i vincoli legali, che sospendono la conservazione e impediscono l'invalidazione di un registro. + Riassegnare bozze di registro + Chi può trasferire una bozza di registro a un altro autore, ad esempio quando l'autore originale non è disponibile. Gestire i dati di prevenzione Chi può creare e modificare occupazioni, programmi di ispezione e raccolte di norme, idranti, permessi e attività di riduzione del rischio comunitario. La lettura dei dati di prevenzione richiede solo l'accesso ai Registri; le indagini sono regolate dal permesso sui registri riservati e dall'appartenenza al caso. @@ -1012,4 +2184,190 @@ Definisce chi può trasferire l’inventario tra ubicazioni. Per impostazione predefinita, sono autorizzati gli amministratori del dipartimento. + + Metodi del secondo fattore + + + Scegli quali metodi di verifica valgono come autenticazione a più fattori in questo dipartimento. I codici dell'app di autenticazione (TOTP) sono sempre accettati, quindi disattivare un metodo non blocca mai nessuno; ai membri che lo usavano viene chiesto di verificare di nuovo. + + + Solo il membro responsabile del dipartimento può modificare queste impostazioni. + + + Le passkey non sono ancora disponibili su questo sistema. Queste scelte avranno effetto quando lo saranno. + + + Non ancora disponibile + + + Accetta le passkey per l'accesso e i controlli di sicurezza + + + Una passkey registrata nella stessa app vale come MFA per l'accesso, il cambio di dipartimento e le azioni sensibili. + + + Accetta le passkey per i dati protetti + + + Una passkey vale come MFA per mostrare e modificare i dati protetti. Modificare questa impostazione chiude l'accesso in corso ai dati protetti, quindi i membri verificano di nuovo. + + + Accetta l'approvazione dall'app Responder + + + Dove le passkey sono accettate, un membro può approvare un accesso o una richiesta di dati protetti con la passkey nella sua app Responder. Mai usato per modifiche di sicurezza. Modificare questa impostazione chiude l'accesso in corso ai dati protetti. + + + Accetta l'MFA del tuo provider di identità per l'accesso e i controlli di sicurezza + + + Richiede una mappatura MFA testata nella tua configurazione SSO. + + + Accetta l'MFA del tuo provider di identità per i dati protetti + + + Richiede una mappatura MFA testata nella tua configurazione SSO. Modificare questa impostazione chiude l'accesso in corso ai dati protetti. + + + La tua configurazione SSO non ha una mappatura MFA testata. Salva una mappatura e completane il test prima di accettare l'MFA del tuo provider di identità. + + + Per accettare l'MFA del tuo provider di identità, verifica prima con la tua app di autenticazione o una passkey. L'MFA del provider di identità non può approvare questa modifica. + + + Consenti a una verifica recente all'accesso di aprire i dati protetti + + + I membri che hanno verificato l'MFA all'accesso non devono verificare di nuovo per mostrare i dati protetti entro la finestra di verifica. Modificare questa impostazione chiude l'accesso in corso ai dati protetti. + + + Consenti allo sblocco di un dispositivo condiviso di aprire i dati protetti + + + Su tablet di veicoli e postazioni condivise, la verifica di sblocco recente dell'operatore vale per mostrare i dati protetti. Modificare questa impostazione chiude l'accesso in corso ai dati protetti. + + + Dispositivi condivisi di veicoli e postazioni + + + I tablet dei veicoli e le postazioni di centrale condivise si bloccano quando nessuno li usa e terminano con il turno. Gli operatori sbloccano con la propria app di autenticazione, passkey o approvazione Responder, mai con una password salvata sul dispositivo. Un valore più restrittivo vale anche per le sessioni già in corso. + + + Solo il membro responsabile del dipartimento può modificare i criteri per i dispositivi condivisi. + + + La modalità dispositivo condiviso non è ancora disponibile in questa installazione. Questi valori vengono conservati e non può essere richiesta per un'altra app finché non lo è. + + + Blocca dopo questi minuti di inattività + + + Da 1 a {0} minuti. Conta solo l'attività dell'operatore; gli aggiornamenti in background e gli avvisi in arrivo no. + + + Termina il turno dopo queste ore + + + Da 1 a {0} ore dopo l'accesso, qualunque sia l'attività. Il turno successivo accede di nuovo. + + + Usa sempre la modalità condivisa per + + + Le sessioni di queste app in questo dipartimento si bloccano sempre per inattività e terminano con il turno, qualunque sia l'impostazione dell'installazione. Contano anche gli accessi che non indicano la loro app, quindi si bloccano anche le integrazioni che accedono con una password. Usa versioni delle app che supportano la modalità condivisa. + + + Unità + + + IC (Comando) + + + Centrale + + + Scegli un blocco per inattività da 1 a {0} minuti. + + + Scegli una durata del turno da 1 a {0} ore. + + + La modalità dispositivo condiviso non è ancora disponibile in questa installazione, quindi non può essere richiesta per un'altra app. + + + MFA del provider di identità + + + Indica a Resgrid come il tuo provider di identità segnala di aver verificato un membro con l'MFA. Dove questo dipartimento accetta l'MFA del tuo provider di identità, un accesso o una verifica con uno di questi valori vale come MFA e i membri non hanno bisogno di un autenticatore Resgrid. Ogni modifica deve superare un accesso di prova prima di avere effetto. + + + Prima configura e attiva una configurazione SSO. + + + In vigore: versione {0}, testata il {1}. + + + Non in vigore: la versione {0} non ha ancora superato il test. + + + Nessuna mappatura salvata. + + + Cosa chiede Resgrid + + + Cosa vale come MFA + + + Un valore per riga. I valori devono corrispondere esattamente, maiuscole comprese. Una risposta deve contenere almeno uno dei valori. + + + acr_values da richiedere (OIDC) + + + Richiesta claims (OIDC, JSON) + + + Riferimenti di classe RequestedAuthnContext (SAML) + + + Valori amr (OIDC) + + + Valori acr (OIDC) + + + Valori acrs (OIDC, contesto di autenticazione) + + + Valori AuthnContextClassRef (SAML) + + + Salva mappatura + + + Rimuovi mappatura + + + Mappatura salvata. Avrà effetto quando supererà un accesso di prova. + + + Mappatura rimossa. L'MFA del tuo provider di identità non vale più in questo dipartimento. + + + La mappatura non è utilizzabile: {0} + + + Per modificare la mappatura serve una verifica recente con la tua app di autenticazione o una passkey. L'MFA del tuo provider di identità non può approvare questa modifica. + + + Mappatura MFA del provider di identità + + + Prova con un accesso + + + Il test ti invia ora al provider di identità chiedendo l'MFA. Quando restituisce un valore che la mappatura conta, questa versione entra in vigore. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx index 6dbd6596b..d07b484f0 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Bezpieczeństwo i uprawnienia - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Tutaj możesz ustawić uprawnienia dla Twojego działu, np. którzy użytkownicy lub role mogą tworzyć zgłoszenia albo kto może dodawać i usuwać użytkowników. Zmiany uprawnień zaczną obowiązywać przy następnym logowaniu do aplikacji internetowej Resgrid. - Audit Logs + Dzienniki audytu + + + Filtruj według typu audytu + + + Wszystkie typy audytu + + + Wyszukuj według nazwy użytkownika, identyfikatora użytkownika lub audytu, adresu e-mail, daty/godziny lub typu audytu. Wyszukiwanie i sortowanie działają w obrębie wybranego typu audytu. + + + Szukaj w dziennikach audytu: + + + Nazwa, ID, e-mail, data/godzina lub typ + + + Znacznik czasu + + + Typ + + + Zarejestrowane przez + + + Wynik + + + Komunikat + + + Terminy wyszukiwania + + + Akcje + + + Powodzenie + + + Niepowodzenie + + + Wyświetl + + + Wyświetlanie pozycji od _START_ do _END_ z _TOTAL_ + + + Wyświetlanie pozycji od 0 do 0 z 0 + + + (filtrowane spośród _MAX_ pozycji) + + + Pokaż _MENU_ pozycji + + + Ładowanie... + + + Brak wpisów dziennika audytu + + + Nie znaleziono pasujących wpisów dziennika audytu + + + Pierwsza + + + Ostatnia + + + Następna + + + Poprzednia + + + : aktywuj, aby posortować kolumnę rosnąco + + + : aktywuj, aby posortować kolumnę malejąco + + + System + + + Nieznany + + + Podgląd wpisu dziennika audytu + + + Wpis dziennika audytu + + + ID wpisu audytu: + + + ID działu: + + + Typ audytu: + + + ID typu wpisu: + + + Opis typu: + + + Wynik: + + + Zarejestrowane przez: + + + ID użytkownika: + + + Zarejestrowano (czas lokalny): + + + Zarejestrowano (UTC): + + + Adres IP: + + + Nazwa serwera: + + + ID obiektu: + + + ID działu obiektu: + + + Agent użytkownika: + + + Komunikat: + + + Dane: + + + Nie zarejestrowano + + + Zmieniono ustawienia działu + + + Dodano użytkownika + + + Usunięto użytkownika + + + Dodano grupę + + + Usunięto grupę + + + Zmieniono grupę + + + Dodano jednostkę + + + Usunięto jednostkę + + + Zmieniono jednostkę + + + Zaktualizowano profil + + + Zmieniono uprawnienia + + + Zaktualizowano subskrypcję + + + Utworzono subskrypcję + + + Anulowano subskrypcję + + + Zaktualizowano informacje rozliczeniowe subskrypcji + + + Ponownie aktywowano zgłoszenie + + + Usunięto konto użytkownika + + + Zmodyfikowano subskrypcję dodatku + + + Zażądano usunięcia działu + + + Anulowano żądanie usunięcia działu + + + Usunięto statyczną zmianę + + + Zaktualizowano statyczną zmianę + + + Dodano niestandardowy status + + + Usunięto niestandardowy status + + + Zaktualizowano niestandardowy status + + + Zaktualizowano szczegóły niestandardowego statusu + + + Dodano typ zgłoszenia + + + Edytowano typ zgłoszenia + + + Usunięto typ zgłoszenia + + + Dodano priorytet zgłoszenia + + + Edytowano priorytet zgłoszenia + + + Usunięto priorytet zgłoszenia + + + Dodano typ jednostki + + + Edytowano typ jednostki + + + Usunięto typ jednostki + + + Dodano typ certyfikatu + + + Edytowano typ certyfikatu + + + Usunięto typ certyfikatu + + + Dodano kategorię dokumentów + + + Edytowano kategorię dokumentów + + + Usunięto kategorię dokumentów + + + Dodano dokument + + + Edytowano dokument + + + Usunięto dokument + + + Dodano kategorię notatek + + + Edytowano kategorię notatek + + + Usunięto kategorię notatek + + + Dodano notatkę + + + Edytowano notatkę + + + Usunięto notatkę + + + Dodano kontakt + + + Edytowano kontakt + + + Usunięto kontakt + + + Dodano kategorię kontaktów + + + Edytowano kategorię kontaktów + + + Usunięto kategorię kontaktów + + + Dodano typ notatki kontaktu + + + Edytowano typ notatki kontaktu + + + Usunięto typ notatki kontaktu + + + Utworzono przepływ pracy + + + Zaktualizowano przepływ pracy + + + Usunięto przepływ pracy + + + Dodano krok przepływu pracy + + + Zaktualizowano krok przepływu pracy + + + Usunięto krok przepływu pracy + + + Dodano dane uwierzytelniające przepływu pracy + + + Zaktualizowano dane uwierzytelniające przepływu pracy + + + Usunięto dane uwierzytelniające przepływu pracy + + + Wysłano kod weryfikacyjny kontaktu + + + Potwierdzono weryfikację kontaktu + + + Nieudana weryfikacja kontaktu + + + Włączono 2FA + + + Wyłączono 2FA + + + Zweryfikowano logowanie 2FA + + + Użyto kodu odzyskiwania 2FA + + + Zweryfikowano kontrolę bezpieczeństwa 2FA + + + Utworzono konfigurację SSO + + + Zaktualizowano konfigurację SSO + + + Usunięto konfigurację SSO + + + Udane logowanie SSO + + + Nieudane logowanie SSO + + + Zainicjowano obsługę użytkownika SSO + + + Utworzono użytkownika SCIM + + + Zaktualizowano użytkownika SCIM + + + Dezaktywowano użytkownika SCIM + + + Usunięto użytkownika SCIM + + + Nieudane uwierzytelnienie SCIM + + + Ponownie aktywowano użytkownika SCIM + + + Wylistowano grupy SCIM + + + Wylistowano użytkowników SCIM + + + Odczytano dane użytkownika SCIM + + + Zainicjowano token Bearer SCIM + + + Zamieniono token Bearer SCIM + + + Utworzono definicję UDF + + + Zaktualizowano definicję UDF + + + Usunięto definicję UDF + + + Dodano pole UDF + + + Zaktualizowano pole UDF + + + Usunięto pole UDF + + + Zapisano wartości pól UDF + + + Utworzono trasę + + + Zaktualizowano trasę + + + Usunięto trasę + + + Rozpoczęto trasę + + + Zakończono trasę + + + Anulowano trasę + + + Wstrzymano trasę + + + Wznowiono trasę + + + Zameldowano się na przystanku trasy + + + Wymeldowano się z przystanku trasy + + + Pominięto przystanek trasy + + + Wykryto odchylenie od trasy + + + Przyjęto do wiadomości odchylenie od trasy + + + Utworzono konfigurację czasomierza meldowania + + + Zaktualizowano konfigurację czasomierza meldowania + + + Usunięto konfigurację czasomierza meldowania + + + Utworzono nadpisanie czasomierza meldowania + + + Zaktualizowano nadpisanie czasomierza meldowania + + + Usunięto nadpisanie czasomierza meldowania + + + Wykonano meldunek + + + Włączono czasomierz meldowania w zgłoszeniu + + + Wyłączono czasomierz meldowania w zgłoszeniu + + + Zameldowano się na wydarzeniu kalendarza + + + Wymeldowano się z wydarzenia kalendarza + + + Zaktualizowano czasy zameldowania w kalendarzu + + + Usunięto zameldowanie w kalendarzu + + + Zameldowano administracyjnie w kalendarzu + + + Utworzono wpis dziennika + + + Usunięto wpis dziennika + + + Utworzono test łączności + + + Zaktualizowano test łączności + + + Usunięto test łączności + + + Rozpoczęto uruchomienie testu łączności + + + Utworzono źródło alertów pogodowych + + + Zaktualizowano źródło alertów pogodowych + + + Usunięto źródło alertów pogodowych + + + Włączono źródło alertów pogodowych + + + Wyłączono źródło alertów pogodowych + + + Utworzono strefę alertów pogodowych + + + Zaktualizowano strefę alertów pogodowych + + + Usunięto strefę alertów pogodowych + + + Włączono strefę alertów pogodowych + + + Wyłączono strefę alertów pogodowych + + + Zmieniono ustawienia alertów pogodowych + + + Zmieniono flagę funkcji + + + Zmieniono nadpisanie flagi funkcji + + + Utworzono urządzenie śledzące jednostki + + + Zaktualizowano urządzenie śledzące jednostki + + + Wyłączono urządzenie śledzące jednostki + + + Usunięto urządzenie śledzące jednostki + + + Utworzono dane uwierzytelniające śledzenia jednostek + + + Zamieniono dane uwierzytelniające śledzenia jednostek + + + Cofnięto dane uwierzytelniające śledzenia jednostek + + + Zrealizowano usunięcie działu + + + Usunięto wiadomość czatu w ramach moderacji + + + Wyciszono użytkownika czatu + + + Wyłączono wyciszenie użytkownika czatu + + + Zablokowano użytkownika czatu + + + Odblokowano użytkownika czatu + + + Zablokowano kanał czatu + + + Odblokowano kanał czatu + + + Zarchiwizowano kanał czatu + + + Rozstrzygnięto zgłoszenie treści czatu + + + Zmieniono ustawienia czatu + + + Zażądano eksportu czatu + + + Pobrano eksport czatu + + + Przesłano zgłoszenie do moderacji + + + Ponownie otwarto wniosek o moderację + + + Zakończono wniosek o moderację + + + Pobrano materiał dowodowy moderacji + + + Zresetowano hasło administracyjnie + + + Cofnięto sesje logowania użytkownika + + + Zmieniono wyjątki od kontroli bezpieczeństwa ochrony danych + + + Dodano plan operacyjny kontaktu + + + Zaktualizowano plan operacyjny kontaktu + + + Usunięto plan operacyjny kontaktu + + + Dodano załącznik kontaktu + + + Usunięto załącznik kontaktu + + + Dodano definicję listy kontrolnej + + + Zaktualizowano definicję listy kontrolnej + + + Opublikowano definicję listy kontrolnej + + + Wycofano z użytku definicję listy kontrolnej + + + Usunięto definicję listy kontrolnej + + + Rozpoczęto wypełnianie listy kontrolnej + + + Zapisano postęp listy kontrolnej + + + Przesłano wypełnioną listę kontrolną + + + Poświadczono listę kontrolną jako świadek + + + Dodano plik listy kontrolnej + + + Usunięto plik listy kontrolnej + + + Dodano harmonogram listy kontrolnej + + + Zaktualizowano harmonogram listy kontrolnej + + + Nie wykonano zaplanowanej kontroli listy kontrolnej + + + Pominięto zaplanowaną kontrolę listy kontrolnej + + + Zaktualizowano ustawienia przypomnień list kontrolnych + + + Zmieniono zlecenie pracy + + + Zmieniono inwentarz + + + Zmieniono profil rozliczeniowy + + + Zmieniono cennik + + + Utworzono fakturę + + + Zaktualizowano fakturę + + + Wysłano fakturę + + + Unieważniono fakturę + + + Zarejestrowano płatność za fakturę + + + Zmieniono dane rozliczeniowe działu + + + Nawiązano połączenie płatności + + + Rozłączono połączenie płatności + + + Cofnięto połączenie płatności + + + Wymagane działanie w połączeniu płatności + + + Utworzono żądanie płatności za fakturę + + + Zwrócono płatność za fakturę + + + Zakwestionowano płatność za fakturę + + + Odrzucono webhook płatności + + + Nieudane żądanie płatności za fakturę + + + Wygasłe żądanie płatności za fakturę + + + Dodano certyfikat + + + Zaktualizowano certyfikat + + + Usunięto certyfikat + + + Zmieniono status certyfikatu + + + Zweryfikowano certyfikat + + + Dodano punkty certyfikacji + + + Usunięto punkty certyfikacji + + + Zmieniono wymaganie certyfikacyjne roli + + + Zmieniono ustawienia certyfikatów działu + + + Dodano członka roli + + + Usunięto członka roli + + + Usunięto członka roli z powodu certyfikacji + + + Dodano certyfikat jednostki + + + Zaktualizowano certyfikat jednostki + + + Usunięto certyfikat jednostki + + + Zmieniono status certyfikatu jednostki + + + Utworzono dyslokację + + + Zaktualizowano dyslokację + + + Zmieniono status dyslokacji + + + Zmieniono obsadę dyslokacji + + + Zmieniono sprzęt dyslokacji + + + Dodano plik dyslokacji + + + Usunięto plik dyslokacji + + + Utworzono raport czasu + + + Zaktualizowano raport czasu + + + Przesłano raport czasu + + + Zatwierdzono raport czasu + + + Unieważniono raport czasu + + + Dodano wydatek dyslokacji + + + Zaktualizowano wydatek dyslokacji + + + Usunięto wydatek dyslokacji + + + Utworzono cennik wykonawcy + + + Zaktualizowano cennik wykonawcy + + + Usunięto cennik wykonawcy + + + Zmieniono pozycję cennika wykonawcy + + + Zmieniono dodatek do stawki + + + Utworzono umowę serwisową + + + Zaktualizowano umowę serwisową + + + Zmieniono status umowy serwisowej + + + Usunięto umowę serwisową + + + Dodano dokument zgodności + + + Zaktualizowano dokument zgodności + + + Usunięto dokument zgodności + + + Utworzono ofertę + + + Zaktualizowano ofertę + + + Wysłano ofertę + + + Przyjęto ofertę + + + Odrzucono ofertę + + + Wycofano ofertę + + + Wygasła oferta + + + Przekształcono ofertę + + + Usunięto ofertę + + + Rozliczono raport czasu + + + Wygenerowano fakturę dyslokacji + + + Zmieniono profil agencji Cal OES MARS + + + Zmieniono profil zasobu Cal OES MARS + + + Zmieniono profil stawek Cal OES MARS + + + Sporządzono szkic stawek Cal OES MARS + + + Oznaczono stawki Cal OES MARS jako zweryfikowane + + + Zmieniono porozumienie Cal OES MARS + + + Zaobserwowano porozumienie Cal OES MARS + + + Przygotowano element pracy Cal OES MARS + + + Zwalidowano element pracy Cal OES MARS + + + Obliczono zwrot Cal OES MARS + + + Otwarto element pracy Cal OES MARS do przekazania + + + Zaobserwowano status zewnętrzny Cal OES MARS + + + Zatwierdzono fakturę Cal OES MARS + + + Odrzucono fakturę Cal OES MARS + + + Uzgodniono płatność Cal OES MARS + + + Usunięto element pracy Cal OES MARS + + + Zmieniono profil pracodawcy w kadrach + + + Zmieniono zakład w kadrach + + + Zmieniono zatrudnienie w kadrach + + + Zmieniono wynagrodzenie w kadrach + + + Zaimportowano roczne dane płacowe w kadrach + + + Zmieniono dane demograficzne do danych płacowych + + + Utworzono raport danych płacowych + + + Zwalidowano raport danych płacowych + + + Zamrożono raport danych płacowych + + + Wyeksportowano raport danych płacowych + + + Oznaczono raport danych płacowych jako certyfikowany + + + Skorygowano raport danych płacowych + + + Zmieniono profil kosztów zasobu + + + Zmieniono wykorzystanie zasobów + + + Utworzono przebieg kosztów działań + + + Zamrożono przebieg kosztów działań + + + Ponownie aktywowano użytkownika + + + Zmieniono konfigurację działu + + + Zmieniono przegląd Admin Assist + + + Uzyskano dostęp do diagnostyki Admin Assist + + + Zaktualizowano ustawienia dysponowania z AI + + + Uzyskano dostęp do planu zmian Admin Assist + + + Zmieniono zasady bezpieczeństwa - Permission + Uprawnienie Notatka - Value + Wartość Role - Two-Factor Authentication (2FA) Enforcement + Wymuszanie uwierzytelniania dwuskładnikowego (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Wymagaj od administratorów 2FA z aplikacją uwierzytelniającą. Po włączeniu administratorzy, którzy nie skonfigurowali 2FA, zostaną przekierowani do rejestracji, zanim uzyskają dostęp do funkcji administracyjnych. - Require 2FA for Admins + Wymagaj 2FA od administratorów - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Określa, którzy użytkownicy z uprawnieniami administratora muszą skonfigurować 2FA z aplikacją uwierzytelniającą (Google Authenticator, Microsoft Authenticator, Authy itp.). - Disabled (no requirement) + Wyłączone (brak wymogu) - Department Admins + Managing User + Administratorzy działu + członek zarządzający - Department Admins + Managing User + Group Admins + Administratorzy działu + członek zarządzający + administratorzy grup - 2FA enforcement setting saved. + Zapisano ustawienie wymuszania 2FA. - Failed to save 2FA enforcement setting. + Nie udało się zapisać ustawienia wymuszania 2FA. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Nie można włączyć wymuszania 2FA: członek zarządzający tym działem nie ma włączonego uwierzytelniania dwuskładnikowego na swoim koncie. Członek zarządzający musi włączyć 2FA, zanim będzie można włączyć to ustawienie. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Nie można włączyć wymuszania 2FA: nie masz włączonego uwierzytelniania dwuskładnikowego na własnym koncie. Aby wymagać 2FA od innych, musisz najpierw włączyć je u siebie. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Nie można włączyć wymuszania 2FA: ani Ty, ani członek zarządzający nie macie włączonego uwierzytelniania dwuskładnikowego. Zanim będzie można włączyć to ustawienie, 2FA muszą włączyć zarówno Ty, jak i członek zarządzający. - This setting is locked until the above conditions are met. + To ustawienie jest zablokowane, dopóki powyższe warunki nie zostaną spełnione. - Permission + Uprawnienie Notatka - Selection + Wybór - Group Only + Tylko grupa Role - No Roles + Brak ról - N/A + n/d + + + Wszyscy + + + Administratorzy działu + + + Administratorzy działu i grup + + + Administratorzy działu i wybrane role + + + Administratorzy działu i grup oraz wybrane role - Security and Permissions + Bezpieczeństwo i uprawnienia + + + Strona główna - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Tutaj możesz ustawić uprawnienia dla Twojego działu, np. którzy użytkownicy lub role mogą tworzyć zgłoszenia albo kto może dodawać i usuwać użytkowników. Zmiany uprawnień zaczną obowiązywać przy następnym logowaniu do aplikacji internetowej Resgrid. - Who can Add Users + Kto może dodawać użytkowników - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Ta opcja określa, kto może dodawać użytkowników/personel do działu. Domyślnie użytkowników mogą dodawać tylko administratorzy działu (i członek zarządzający). Można jednak zezwolić na to również administratorom grup (wyłącznie w obrębie grupy, której są administratorami). - Who can Remove Users + Kto może usuwać użytkowników - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Ta opcja określa, kto może usuwać użytkowników/personel z działu. Domyślnie użytkowników mogą usuwać tylko administratorzy działu (i członek zarządzający). Można jednak zezwolić na to również administratorom grup (wyłącznie w odniesieniu do użytkowników z grupy, której są administratorami). - Who can Create Calls + Kto może tworzyć zgłoszenia - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Ta opcja określa, kto może ręcznie tworzyć zgłoszenia w systemie Resgrid. Domyślnie zgłoszenia mogą tworzyć wszyscy. - Who can Delete Calls + Kto może usuwać zgłoszenia - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Ta opcja określa, kto może usuwać zgłoszenia z systemu Resgrid. Domyślnie zgłoszenia mogą usuwać wszyscy. - Who can Close Calls + Kto może zamykać zgłoszenia - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Ta opcja określa, kto może zamykać zgłoszenia w systemie Resgrid. Domyślnie zgłoszenia mogą zamykać wszyscy. - Who can Add Data To Calls + Kto może dodawać dane do zgłoszeń - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Ta opcja określa, kto może dodawać do zgłoszeń w systemie Resgrid dane, takie jak zdjęcia, notatki i pliki. Domyślnie dane do zgłoszeń mogą dodawać wszyscy. - Who can Create Trainings + Kto może tworzyć szkolenia - This option determines who can create trainings. By default only Department Admins can create trainings. + Ta opcja określa, kto może tworzyć szkolenia. Domyślnie szkolenia mogą tworzyć tylko administratorzy działu. - Who can Add Documents + Kto może dodawać dokumenty - This option determines who can add documents. By default Everyone can add documents. + Ta opcja określa, kto może dodawać dokumenty. Domyślnie dokumenty mogą dodawać wszyscy. - Who can Create Calendar Entries + Kto może tworzyć wpisy kalendarza - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Ta opcja określa, kto może tworzyć wpisy kalendarza. Domyślnie wpisy kalendarza mogą tworzyć wszyscy. - Who can Create Notes + Kto może tworzyć notatki - This option determines who can create notes. By default Everyone can create notes. + Ta opcja określa, kto może tworzyć notatki. Domyślnie notatki mogą tworzyć wszyscy. - Who can Add Log Entries + Kto może dodawać wpisy dziennika - This option determines who can add log entries. By default Everyone can add log entries. + Ta opcja określa, kto może dodawać wpisy dziennika. Domyślnie wpisy dziennika mogą dodawać wszyscy. - Who can Create Shifts + Kto może tworzyć zmiany - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Ta opcja określa, kto może tworzyć i edytować zmiany. Domyślnie zmiany mogą tworzyć i edytować tylko administratorzy działu. - Who can View Personal Info + Kto może wyświetlać dane osobowe - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Ta opcja określa, kto może wyświetlać dane osobowe (PII) personelu w systemie, np. adres e-mail, numery telefonów itp. Domyślnie te informacje mogą wyświetlać wszyscy. - Who can Adjust Inventory + Kto może korygować inwentarz - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Ta opcja określa, kto może korygować stany inwentarza w systemie. Domyślnie inwentarz mogą korygować wszyscy. - Who can see the Location of Personnel + Kto może widzieć lokalizację personelu - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Ta opcja określa, kto może widzieć lokalizację personelu na mapach. Aby ograniczyć ją tylko do administratorów grup i ról w obrębie grupy, zaznacz opcję Tylko grupa. - Who can see the Location of Units + Kto może widzieć lokalizację jednostek - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Ta opcja określa, kto może widzieć lokalizację jednostek na mapach. Aby ograniczyć ją tylko do administratorów grup i ról w obrębie grupy, zaznacz opcję Tylko grupa. - Who can send messages + Kto może wysyłać wiadomości - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Ta opcja określa, kto może tworzyć i wysyłać wiadomości (wewnętrzną pocztę systemu). Domyślnie wiadomości mogą tworzyć i wysyłać wszyscy. - Who can view users + Kto może wyświetlać użytkowników - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + Domyślnie wszyscy użytkownicy widzą wszystkich innych użytkowników w systemie. Ta opcja pozwala ograniczyć, kto może widzieć użytkowników w systemie. - Who can view units + Kto może wyświetlać jednostki - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + Domyślnie wszyscy użytkownicy widzą wszystkie jednostki w systemie. Ta opcja pozwala ograniczyć, kto może wyświetlać jednostki w systemie. - Who can view Contacts + Kto może wyświetlać kontakty - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + Domyślnie wszyscy użytkownicy widzą wszystkie kontakty w systemie. Ta opcja pozwala ograniczyć, kto może wyświetlać kontakty w systemie. Użytkownik, który nie może wyświetlać kontaktów, nie będzie też mógł dodawać ich do zgłoszenia. - Who can edit or create Contacts + Kto może edytować lub tworzyć kontakty - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + Domyślnie wszyscy użytkownicy mogą tworzyć i edytować kontakty w systemie. Ta opcja pozwala ograniczyć, kto może tworzyć lub edytować kontakty w systemie. - Who can delete Contacts + Kto może usuwać kontakty - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + Domyślnie wszyscy użytkownicy mogą usuwać kontakty w systemie. Ta opcja pozwala ograniczyć, kto może usuwać kontakty w systemie. - Who can Create/Edit Workflows + Kto może tworzyć/edytować przepływy pracy - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Ta opcja określa, kto może tworzyć, edytować i usuwać przepływy pracy oraz ich kroki. Domyślnie przepływami pracy mogą zarządzać tylko administratorzy działu. - Who can Manage Workflow Credentials + Kto może zarządzać danymi uwierzytelniającymi przepływów pracy - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Ta opcja określa, kto może tworzyć, edytować i usuwać zaszyfrowane dane uwierzytelniające używane przez akcje przepływów pracy (np. hasła SMTP, klucze API). Domyślnie danymi uwierzytelniającymi mogą zarządzać tylko administratorzy działu. - Who can View Workflow Runs + Kto może wyświetlać uruchomienia przepływów pracy - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Ta opcja określa, kto może wyświetlać historię wykonywania przepływów pracy, dzienniki uruchomień i pulpity kondycji. Domyślnie uruchomienia przepływów pracy mogą wyświetlać tylko administratorzy działu. - Single Sign-On (SSO) & SCIM + Logowanie jednokrotne (SSO) i SCIM - Single Sign-On & Identity Provisioning + Logowanie jednokrotne i inicjowanie obsługi tożsamości SSO / SCIM - Security Policy + Zasady bezpieczeństwa - Add OIDC Config + Dodaj konfigurację OIDC - Add SAML 2.0 Config + Dodaj konfigurację SAML 2.0 - What is SSO & SCIM? + Czym są SSO i SCIM? - Single Sign-On (SSO) + Logowanie jednokrotne (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Pozwól członkom działu logować się przy użyciu ich istniejącej tożsamości firmowej (Microsoft Entra ID, Okta, Google Workspace itp.) zamiast osobnego hasła Resgrid. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC — nowoczesny protokół, idealny dla Entra, Okta, Auth0, Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 — szeroko obsługiwany przez dostawców tożsamości w administracji publicznej i przedsiębiorstwach - SCIM 2.0 Provisioning + Inicjowanie obsługi SCIM 2.0 - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Automatycznie synchronizuj użytkowników z Twojego dostawcy tożsamości. Gdy dodajesz lub usuwasz pracowników w katalogu firmowym, Resgrid automatycznie odzwierciedla te zmiany — bez ręcznego zapraszania i usuwania. - Auto-create new members when added in your IdP + Automatyczne tworzenie nowych członków po dodaniu ich w IdP - Disable/remove members when deprovisioned + Wyłączanie/usuwanie członków po cofnięciu inicjowania obsługi - Keep names & email addresses in sync + Synchronizacja imion, nazwisk i adresów e-mail - Security Policy + Zasady bezpieczeństwa - Enforce department-wide compliance controls alongside SSO: + Oprócz SSO wymuszaj w całym dziale mechanizmy kontroli zgodności: - Mandate MFA for all members + Wymóg MFA dla wszystkich członków - Restrict login to SSO only (disable passwords) + Logowanie wyłącznie przez SSO (wyłączenie haseł) - Limit logins to specific IP CIDR ranges + Ograniczenie logowań do określonych zakresów IP (CIDR) - Set password expiration & complexity rules + Reguły wygasania i złożoności haseł - Classify data level (Unclassified / CUI / Confidential) + Klasyfikacja poziomu danych (jawne / CUI / poufne) - Mobile App Discovery URL + Adres URL wykrywania dla aplikacji mobilnej - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + Aplikacja mobilna Resgrid używa tego adresu URL, aby wykryć Twoje ustawienia SSO przed wyświetleniem ekranu logowania. W razie potrzeby udostępnij go zespołowi odpowiedzialnemu za urządzenia mobilne. - Copy + Kopiuj - Copied to clipboard! + Skopiowano do schowka! - SSO Configurations + Konfiguracje SSO - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Nie ma jeszcze konfiguracji SSO. Użyj przycisków powyżej, aby dodać OIDC lub SAML 2.0. - Provider + Dostawca - Identifier / Endpoint + Identyfikator / punkt końcowy Status - Local Login + Logowanie lokalne - Auto-Provision + Automatyczne tworzenie kont SCIM - Created + Utworzono Akcje - Enabled + Włączona - Disabled + Wyłączona Tak - SSO Only + Tylko SSO - On + Wł. - Off + Wył. Aktywny - No Token + Brak tokenu - Off + Wył. Edytuj @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Potwierdź usunięcie - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Czy na pewno chcesz usunąć konfigurację SSO {0}? Tej operacji nie można cofnąć. + + + Nieprawidłowy typ dostawcy. + + + Konfiguracja SSO dla {0} już istnieje. Użyj przycisku Edytuj, aby ją zmienić. + + + Identyfikator klienta OIDC jest wymagany. + + + Adres wystawcy OIDC musi być prawidłowym adresem URL HTTPS. + + + Do weryfikacji asercji SAML wymagany jest certyfikat podpisujący dostawcy tożsamości. + + + Adres URL logowania jednokrotnego dostawcy tożsamości musi być prawidłowym adresem URL HTTPS. + + + Konfiguracja SSO {0} została utworzona. + + + Konfiguracja SSO została zaktualizowana. + + + Konfiguracja SSO {0} została usunięta. Anuluj @@ -453,10 +1569,10 @@ Usuń - New SSO Configuration + Nowa konfiguracja SSO - Edit SSO Configuration + Edytuj konfigurację SSO Nowy @@ -465,226 +1581,256 @@ Edytuj - Step 1 + Krok 1 - Provider & Basic Settings + Dostawca i ustawienia podstawowe - Identity Provider Protocol + Protokół dostawcy tożsamości + + + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + + + SAML 2.0 — większość dostawców tożsamości w przedsiębiorstwach i administracji publicznej - Enable this SSO configuration + Włącz tę konfigurację SSO - Only one configuration per provider type is active at a time. + W danym momencie aktywna może być tylko jedna konfiguracja danego typu dostawcy. - Allow local password login + Zezwalaj na lokalne logowanie hasłem - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + Po zaznaczeniu użytkownicy mogą oprócz SSO nadal logować się nazwą użytkownika i hasłem. Aby wymusić logowanie wyłącznie przez SSO, wyłącz tę opcję i jednocześnie włącz opcję Wymagaj SSO w Zasadach bezpieczeństwa. - Auto-provision new users + Automatycznie twórz konta nowych użytkowników - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Automatycznie tworzy konto Resgrid, gdy użytkownik po raz pierwszy uwierzytelni się przez SSO i nie zostanie znaleziony pasujący adres e-mail. Pozostaw wyłączone, aby najpierw wymagać ręcznego zaproszenia. - Enable SCIM 2.0 provisioning + Włącz inicjowanie obsługi SCIM 2.0 - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Pozwala Twojemu dostawcy tożsamości automatycznie tworzyć, aktualizować i dezaktywować członków. Po zapisaniu przejdź na stronę Konfiguracja SCIM, aby wygenerować token Bearer. - Default Rank for Auto-Provisioned Users + Domyślny stopień dla automatycznie tworzonych użytkowników + + + (Brak domyślnego stopnia) - Optional. Applied only when Auto-Provision creates a new member. + Opcjonalne. Stosowane tylko wtedy, gdy nowy członek jest tworzony automatycznie. - Step 2 — OIDC + Krok 2 — OIDC - OpenID Connect Settings + Ustawienia OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Gdzie znaleźć te wartości: u swojego dostawcy tożsamości zarejestruj Resgrid jako klienta publicznego (PKCE, bez sekretu klienta w przypadku aplikacji mobilnych) lub jako aplikację internetową (z sekretem dla przepływów po stronie serwera). Skopiuj identyfikator klienta i adres URL wystawcy z zarejestrowanej aplikacji. - Authority / Issuer URL + Adres URL wystawcy (Authority / Issuer) https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Przykłady: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com - Client ID + Identyfikator klienta xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + Publiczny identyfikator klienta nadany przez Twojego dostawcę tożsamości. Można go bezpiecznie wyświetlać — nie jest tajny. - Client Secret + Sekret klienta - Secret stored + Sekret zapisany - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Przechowywany w postaci zaszyfrowanej przy użyciu klucza specyficznego dla Twojego działu. Pozostaw puste, jeśli używasz PKCE (aplikacje mobilne/SPA — sekret nie jest potrzebny). - Leave blank to keep the existing secret unchanged. + Pozostaw puste, aby zachować obecny sekret bez zmian. - Step 2 — SAML 2.0 + Krok 2 — SAML 2.0 - SAML 2.0 Settings + Ustawienia SAML 2.0 - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Gdzie znaleźć te wartości: u swojego dostawcy tożsamości utwórz nową aplikację SAML. Wpisz w niej pokazane poniżej identyfikator jednostki SP (SP Entity ID) i adres URL ACS, a następnie wklej tutaj adres URL metadanych lub certyfikat dostawcy tożsamości. - SP Entity ID (enter this into your IdP) + Identyfikator jednostki SP (wpisz go u dostawcy tożsamości) - ACS URL (Assertion Consumer Service — enter this into your IdP) + Adres URL ACS (usługa odbioru asercji — wpisz go u dostawcy tożsamości) - This URL uses an encrypted department token — never exposes your plain department code. + Ten adres URL używa zaszyfrowanego tokenu działu — nigdy nie ujawnia jawnego kodu Twojego działu. - SP Entity ID (editable) + Identyfikator jednostki SP (edytowalny) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Musi dokładnie odpowiadać wartości wpisanej u dostawcy tożsamości. - IdP Metadata URL + Adres URL metadanych dostawcy tożsamości https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid będzie okresowo pobierać ten adres URL, aby mieć aktualne certyfikaty dostawcy tożsamości. - ACS URL (stored) + Adres URL ACS (zapisany) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Wypełniany automatycznie na podstawie wygenerowanego powyżej adresu URL. Zmień go tylko wtedy, gdy u dostawcy tożsamości skonfigurowano inny adres URL ACS. + + + Adres URI przekierowania dla logowania rozpoczętego w Resgrid (wpisz go u dostawcy tożsamości) + + + Dodaj go obok już zarejestrowanych adresów URI przekierowania; starsze wersje aplikacji Resgrid nadal korzystają ze swoich. + + + Adresy URI przekierowania dla logowania w aplikacjach Resgrid (wpisz każdy z nich u dostawcy tożsamości) + + + Każda aplikacja i wersja internetowa każdej z nich wraca na własny adres, więc zarejestruj je wszystkie. Korzystają z nich starsze wersje aplikacji oraz aplikacje, których logowanie nie jest rozpoczynane w Resgrid. + + + {0} (web) + + + Adres URL logowania jednokrotnego dostawcy tożsamości + + + Adres logowania SAML dostawcy tożsamości (powiązanie HTTP-Redirect). Potrzebny do logowania rozpoczętego w aplikacjach i witrynie Resgrid. - IdP Signing Certificate (PEM) + Certyfikat podpisujący dostawcy tożsamości (PEM) - Certificate stored + Certyfikat zapisany - Encrypted at rest. Leave blank to keep the existing certificate. + Przechowywany w postaci zaszyfrowanej. Pozostaw puste, aby zachować obecny certyfikat. - SP Signing Key (PEM, optional) + Klucz podpisujący SP (PEM, opcjonalny) - Key stored + Klucz zapisany - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Przechowywany w postaci zaszyfrowanej. Pozostaw puste, jeśli Twój dostawca tożsamości nie wymaga podpisanych żądań. - Step 3 + Krok 3 - Attribute Mapping + Mapowanie atrybutów - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Przypisz nazwy oświadczeń wysyłanych przez Twojego dostawcę tożsamości do pól użytkownika Resgrid. Pozostaw puste, aby automatycznie używać standardowych identyfikatorów URI oświadczeń. Wartością jest nazwa oświadczenia w takiej postaci, w jakiej wysyła ją dostawca tożsamości. - Attribute Mapping JSON + Mapowanie atrybutów (JSON) - Must be valid JSON. Use the quick-fill buttons on the right. + Musi to być prawidłowy JSON. Użyj przycisków szybkiego wypełniania po prawej stronie. - Quick-fill presets + Szablony szybkiego wypełniania - Click to auto-fill the mapping JSON with known defaults for your IdP. + Kliknij, aby automatycznie wypełnić JSON mapowania znanymi wartościami domyślnymi dla Twojego dostawcy tożsamości. - Supported Resgrid field keys + Obsługiwane klucze pól Resgrid - Key + Klucz Opis - User's email address + Adres e-mail użytkownika - Given / first name + Imię - Surname / family name + Nazwisko - Unique IdP subject (for account linking) + Unikatowy podmiot (subject) z IdP (do łączenia kont) - Create Configuration + Utwórz konfigurację - Save Changes + Zapisz zmiany Anuluj - SCIM 2.0 Setup + Konfiguracja SCIM 2.0 - SCIM 2.0 Provisioning Setup + Konfiguracja inicjowania obsługi SCIM 2.0 - SCIM Setup + Konfiguracja SCIM - Back to SSO + Powrót do SSO - Important — Copy your SCIM Bearer Token Now + Ważne — skopiuj teraz swój token Bearer SCIM - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Ten token jest wyświetlany tylko raz i nie można go ponownie pobrać. Skopiuj go natychmiast i wklej do konfiguracji SCIM swojego dostawcy tożsamości. Jeśli go zgubisz, kliknij Zamień token SCIM, aby wygenerować nowy. - Copy Token + Kopiuj token - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Ten token jest przechowywany w bazie danych Resgrid w postaci zaszyfrowanej przy użyciu klucza szyfrowania specyficznego dla Twojego działu. - SCIM Status + Status SCIM - Provider + Dostawca - SCIM Enabled + SCIM włączone Tak @@ -693,61 +1839,61 @@ Nie - Bearer Token + Token Bearer - Configured + Skonfigurowany - Not Set + Nieustawiony - Rotate SCIM Token + Zamień token SCIM - Generate SCIM Token + Wygeneruj token SCIM - Rotating generates a new token and invalidates the old one. + Zamiana generuje nowy token i unieważnia poprzedni. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Czy na pewno? Każda istniejąca integracja SCIM przestanie działać, dopóki nie zaktualizujesz tokenu u swojego dostawcy tożsamości. - SCIM Connector Settings + Ustawienia łącznika SCIM - Enter these values into your identity provider's SCIM provisioning configuration. + Wprowadź te wartości w konfiguracji inicjowania obsługi SCIM swojego dostawcy tożsamości. - Setting + Ustawienie - Value + Wartość - SCIM Connector Base URL + Bazowy adres URL łącznika SCIM - Authentication Method + Metoda uwierzytelniania - Authorization Header + Nagłówek autoryzacji - Custom Header Name + Nazwa nagłówka niestandardowego - Custom Header Value + Wartość nagłówka niestandardowego - Supported Resources + Obsługiwane zasoby - Supported Update Method + Obsługiwana metoda aktualizacji - Step-by-Step Setup Guide + Przewodnik konfiguracji krok po kroku Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Inne / ogólne - Any SCIM 2.0 compatible client can be configured with the following: + Każdego klienta zgodnego ze SCIM 2.0 można skonfigurować przy użyciu następujących ustawień: - Resgrid SCIM field mapping + Mapowanie pól SCIM w Resgrid - Parameter + Parametr - SCIM field + Pole SCIM - Resgrid effect + Skutek w Resgrid @@ -814,112 +1960,136 @@ Aby uzyskać szczegółowe instrukcje specyficzne dla IdP (Okta, Microsoft Entra ID, Google Workspace) i zarządzanie tokenami, odwiedź stronę konfiguracji SCIM dla każdej konfiguracji. - Department Security Policy + Zasady bezpieczeństwa działu - Department Security Policy + Zasady bezpieczeństwa działu - Security Policy + Zasady bezpieczeństwa - Back to SSO + Powrót do SSO - Security policy saved successfully. + Zasady bezpieczeństwa zostały zapisane. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Brak aktywnej konfiguracji SSO. Poniżej możesz skonfigurować zasady haseł, ale opcji Wymagaj SSO nie można włączyć, dopóki nie będzie aktywnej konfiguracji SSO. - Configure SSO → + Skonfiguruj SSO → - Authentication Controls + Ustawienia uwierzytelniania - Require MFA for all members + Wymagaj MFA od wszystkich członków - Members who have not enrolled in MFA will be prompted to do so on next login. + Członkowie, którzy nie skonfigurowali MFA, zostaną o to poproszeni przy następnym logowaniu. - Require SSO — disable password login + Wymagaj SSO — wyłącz logowanie hasłem - Requires active SSO config + Wymaga aktywnej konfiguracji SSO - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Ostrzeżenie: włączenie tej opcji blokuje wszystkie logowania nazwą użytkownika i hasłem. Przed włączeniem upewnij się, że co najmniej jeden administrator pomyślnie przetestował logowanie przez SSO. - Session Timeout (minutes) + Limit czasu sesji (minuty) - 0 = use system default. 480 = 8 hours. + 0 = domyślna wartość systemowa. 480 = 8 godzin. - Max Concurrent Sessions per User + Maks. liczba jednoczesnych sesji na użytkownika - 0 = unlimited. Government environments typically set 1. + 0 = bez limitu. W środowiskach rządowych zwykle ustawia się 1. - Allowed IP Ranges (CIDR) + Dozwolone zakresy IP (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Jeden blok CIDR w wierszu lub wartości rozdzielone przecinkami. Puste = zezwalaj na wszystkie. Logowania spoza tych zakresów będą odrzucane. - Data Classification Level + Poziom klasyfikacji danych + + + Jawne + + + CUI - Kontrolowane informacje nieklasyfikowane + + + Poufne - Used for compliance reporting and audit logs. + Używany w raportach zgodności i dziennikach audytu. - Password Policy + Zasady haseł - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + Zasady haseł dotyczą tylko logowań lokalnych (bez SSO). Jeśli powyżej włączysz opcję Wymagaj SSO, te ustawienia nie będą miały żadnego wpływu. - Password Expiration (days) + Ważność hasła (dni) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = hasła nigdy nie wygasają. W środowiskach CUI typowa wartość to 90. - Minimum Password Length + Minimalna długość hasła - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Co najmniej 8 (domyślna wartość systemowa). Zasady działu mogą ją tylko zwiększyć. NIST zaleca 12+; CUI wymaga 14+. + + + Złożoność hasła wymuszana przez system + + + Wszystkie konta Resgrid muszą spełniać poniższy standard złożoności hasła. Nie można go wyłączyć zasadami działu. + + + Co najmniej 8 znaków (lub minimalna długość ustawiona powyżej dla działu) + + + Co najmniej jedna cyfra + + + Co najmniej jedna wielka i jedna mała litera - Require password complexity + Wymagaj złożoności hasła - Enforces at least one uppercase letter, one digit, and one special character. + Wymusza co najmniej jedną wielką literę, jedną cyfrę i jeden znak specjalny. - Quick presets + Gotowe ustawienia - Government / CUI + Sektor rządowy / CUI - Standard Enterprise + Standard firmowy - Minimal + Minimalny - Save Security Policy + Zapisz zasady bezpieczeństwa Anuluj - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Nie można włączyć logowania wyłącznie przez SSO: nie istnieje aktywna konfiguracja SSO. Najpierw utwórz i włącz konfigurację SSO. Hasło nie może być puste. Hasło musi zawierać co najmniej jedną cyfrę. @@ -927,32 +2097,34 @@ Hasło musi zawierać co najmniej jedną małą literę. Hasło musi mieć co najmniej {0} znaków. Minimalna długość hasła nie może być mniejsza niż systemowe minimum wynoszące 8 znaków. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. + Usuwanie wpisów dziennika + Kto w Twoim dziale może usuwać wpisy dziennika + Korzystanie z synchronizacji kalendarza + Określa, kto może aktywować i używać adresów URL subskrypcji kalendarza do synchronizowania wydarzeń kalendarza Resgrid z zewnętrznymi aplikacjami kalendarza. + Logowanie do aplikacji Dispatch + Określa, kto może logować się do aplikacji Dispatch. Dispatch pokazuje niepubliczną komunikację dowodzenia, jednostek i ratowników dla każdego zdarzenia, więc ogranicz to uprawnienie, jeśli nie wszyscy Twoi członkowie są dyspozytorami. + Logowanie do aplikacji dowodzenia + Określa, kto może działać jako dowódca: logować się do aplikacji IC, obejmować dowodzenie zdarzeniem przy zgłoszeniu i wyświetlać tablice dowodzenia. Zawężenie tego ustawienia względem opcji Wszyscy pozwala też wybranym osobom pomagać przy dowolnej tablicy dowodzenia (przydzielać i przesuwać zasoby, obsługiwać liczniki czasu i ewidencję personelu) bez zajmowania na niej stanowiska ICS — przydaje się to, gdy dyspozytorzy mają pomagać w aplikacji Dispatch. Przy ustawieniu Wszyscy działania na tablicy pozostają ograniczone do dowódcy zdarzenia i przypisanych ról ICS. Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Te uprawnienia określają, kto może pracować z danymi zaszyfrowanymi (chronionymi), gdy aktywny jest dodatek Advanced Data Protection. Każde wyświetlenie lub edycja wymaga dodatkowo niedawnej weryfikacji dwuskładnikowej; te ustawienia określają, kto w ogóle może podjąć taką próbę. W przeciwieństwie do większości uprawnień Resgrid nieustawione wartości przyjmują domyślnie pokazaną, restrykcyjną opcję. + Zarządzanie ustawieniami ochrony danych + Kto może zmieniać ustawienia Advanced Data Protection, takie jak okno weryfikacji i opcje treści powiadomień. Zakup, rejestracja i anulowanie zawsze pozostają zastrzeżone dla członka zarządzającego działem. + Wyświetlanie chronionych danych zgłoszeń + Kto może wyświetlać chronione pola zgłoszeń (charakter zgłoszenia, adres, dane kontaktowe, notatki) po weryfikacji dwuskładnikowej. Domyślnie: Wszyscy, ponieważ personel biorący udział w akcji musi móc przeczytać treść dysponowania. + Edytowanie chronionych danych zgłoszeń + Kto może edytować chronione pola zgłoszeń po weryfikacji dwuskładnikowej. Domyślnie: Wszyscy, zgodnie ze zwykłym przebiegiem obsługi zgłoszeń. + Wyświetlanie chronionych danych personelu + Kto może wyświetlać chronione informacje o personelu (identyfikatory pracownicze, kontakty alarmowe) po weryfikacji dwuskładnikowej. Domyślnie: administratorzy działu. + Wyświetlanie chronionych danych kontaktów + Kto może wyświetlać chronione informacje kontaktowe (imiona i nazwiska, numery telefonów, numery dokumentów tożsamości, lokalizacje) po weryfikacji dwuskładnikowej. Domyślnie: administratorzy działu. + Wyświetlanie chronionych danych operacyjnych + Kto może wyświetlać chronione treści operacyjne (dzienniki, przesłane formularze, notatki i załączniki dowodzenia zdarzeniem) po weryfikacji dwuskładnikowej. Domyślnie: administratorzy działu i grup. + Eksportowanie danych chronionych + Kto może eksportować dane zawierające pola chronione. Wyeksportowane dane wychodzą poza ochronę Resgrid, dlatego każdy eksport jest osobno audytowany. Domyślnie: administratorzy działu; opcja Wszyscy celowo nie jest dostępna. + Konfigurowanie dostarczania danych chronionych + Kto może zmieniać sposób, w jaki treści chronione opuszczają Resgrid przez powiadomienia push, SMS, e-mail i połączenia głosowe. Domyślnie: administratorzy działu; opcja Wszyscy celowo nie jest dostępna. + Awaryjny dostęp typu break-glass + Kto może korzystać z audytowanej ścieżki awaryjnego dostępu do danych chronionych. Działa ona tylko wtedy, gdy dostęp awaryjny (break-glass) jest włączony w zasadach ochrony działu, wymaga podania powodu, który jest zapisywany, i powiadamia dział. Domyślnie: administratorzy działu; opcja Wszyscy celowo nie jest dostępna. Raporty Te uprawnienia sterują modułem Raporty, następcą Dzienników. Jak w przypadku większości uprawnień Resgrid, nieustawiony wiersz przyjmuje pokazaną wartość domyślną, odpowiadającą obecnemu zachowaniu Dzienników. Aktywacja Raportów dla wydziału kopiuje też ustawienia Utwórz wpis dziennika i Usuń wpis dziennika do odpowiednich wierszy, chyba że zostały już tu ustawione. Raporty nie są jeszcze aktywne dla tego wydziału. Możesz przygotować te ustawienia teraz; zaczną obowiązywać po aktywacji Raportów. @@ -1012,4 +2184,190 @@ Określa, kto może przesuwać inwentarz między lokalizacjami. Domyślnie uprawnieni są administratorzy departamentu. + + Metody drugiego składnika + + + Wybierz, które metody weryfikacji liczą się w tym departamencie jako uwierzytelnianie wieloskładnikowe. Kody z aplikacji uwierzytelniającej (TOTP) są zawsze akceptowane, więc wyłączenie metody nikogo nie blokuje; członkowie, którzy z niej korzystali, zostaną poproszeni o ponowną weryfikację. + + + Tylko członek zarządzający departamentem może zmieniać te ustawienia. + + + Klucze dostępu nie są jeszcze dostępne w tym systemie. Te ustawienia zaczną obowiązywać, gdy będą. + + + Jeszcze niedostępne + + + Akceptuj klucze dostępu przy logowaniu i kontrolach bezpieczeństwa + + + Klucz dostępu zarejestrowany w tej samej aplikacji liczy się jako MFA przy logowaniu, zmianie departamentu i działaniach wrażliwych. + + + Akceptuj klucze dostępu dla danych chronionych + + + Klucz dostępu liczy się jako MFA przy wyświetlaniu i edytowaniu danych chronionych. Zmiana kończy bieżący dostęp do danych chronionych, więc członkowie weryfikują się ponownie. + + + Akceptuj zatwierdzenie w aplikacji Responder + + + Tam, gdzie klucze dostępu są akceptowane, członek może zatwierdzić logowanie lub prośbę o dane chronione kluczem dostępu w swojej aplikacji Responder. Nigdy nie służy do zmian bezpieczeństwa. Zmiana kończy bieżący dostęp do danych chronionych. + + + Akceptuj MFA Twojego dostawcy tożsamości przy logowaniu i kontrolach bezpieczeństwa + + + Wymaga przetestowanego mapowania MFA w konfiguracji SSO. + + + Akceptuj MFA Twojego dostawcy tożsamości dla danych chronionych + + + Wymaga przetestowanego mapowania MFA w konfiguracji SSO. Zmiana kończy bieżący dostęp do danych chronionych. + + + Twoja konfiguracja SSO nie ma przetestowanego mapowania MFA. Zapisz mapowanie i ukończ jego test, zanim zaakceptujesz MFA dostawcy tożsamości. + + + Aby zaakceptować MFA dostawcy tożsamości, najpierw zweryfikuj się aplikacją uwierzytelniającą lub kluczem dostępu. MFA dostawcy tożsamości nie może zatwierdzić tej zmiany. + + + Pozwól, aby niedawna weryfikacja przy logowaniu otwierała dane chronione + + + Członkowie, którzy przeszli MFA przy logowaniu, nie muszą weryfikować się ponownie, aby wyświetlić dane chronione w oknie weryfikacji. Zmiana kończy bieżący dostęp do danych chronionych. + + + Pozwól, aby odblokowanie współdzielonego urządzenia otwierało dane chronione + + + Na współdzielonych tabletach w pojazdach i stanowiskach pracy niedawna weryfikacja odblokowania operatora wystarcza do wyświetlenia danych chronionych. Zmiana kończy bieżący dostęp do danych chronionych. + + + Współdzielone urządzenia w pojazdach i na stanowiskach + + + Współdzielone tablety w pojazdach i stanowiska dyspozytorskie blokują się, gdy nikt ich nie używa, i kończą się wraz ze zmianą. Operatorzy odblokowują je własną aplikacją uwierzytelniającą, kluczem dostępu lub zatwierdzeniem w Responder, nigdy hasłem zapisanym na urządzeniu. Surowsza wartość obowiązuje też sesje już trwające. + + + Tylko członek zarządzający departamentem może zmienić zasady dotyczące urządzeń współdzielonych. + + + Tryb urządzenia współdzielonego nie jest jeszcze dostępny w tej instalacji. Te wartości zostaną zachowane, a trybu nie można wymagać dla innej aplikacji, dopóki nie będzie dostępny. + + + Zablokuj po tylu minutach bezczynności + + + Od 1 do {0} minut. Liczy się tylko aktywność samego operatora; aktualizacje w tle i przychodzące alerty nie. + + + Zakończ zmianę po tylu godzinach + + + Od 1 do {0} godzin po zalogowaniu, niezależnie od aktywności. Następna zmiana loguje się ponownie. + + + Zawsze używaj trybu współdzielonego dla + + + Sesje tych aplikacji w tym departamencie zawsze blokują się przy bezczynności i kończą wraz ze zmianą, niezależnie od ustawienia instalacji. Liczą się też logowania, które nie podają aplikacji, więc integracje logujące się hasłem także się blokują. Używaj wersji aplikacji obsługujących tryb współdzielony. + + + Unit + + + IC (Dowodzenie) + + + Dispatch + + + Wybierz blokadę bezczynności od 1 do {0} minut. + + + Wybierz długość zmiany od 1 do {0} godzin. + + + Tryb urządzenia współdzielonego nie jest jeszcze dostępny w tej instalacji, więc nie można go wymagać dla innej aplikacji. + + + MFA dostawcy tożsamości + + + Wskaż Resgrid, jak Twój dostawca tożsamości informuje, że zweryfikował członka za pomocą MFA. Tam, gdzie ten departament akceptuje MFA dostawcy tożsamości, logowanie lub weryfikacja z jedną z tych wartości liczy się jako MFA, a członkowie nie potrzebują do tego uwierzytelniacza Resgrid. Każda zmiana musi przejść testowe logowanie, zanim zacznie obowiązywać. + + + Najpierw skonfiguruj i włącz konfigurację SSO. + + + Obowiązuje: wersja {0}, przetestowana {1}. + + + Nie obowiązuje: wersja {0} nie przeszła jeszcze testu. + + + Nie zapisano mapowania. + + + O co prosi Resgrid + + + Co liczy się jako MFA + + + Jedna wartość w wierszu. Wartości muszą się zgadzać dokładnie, z wielkością liter. Odpowiedź musi zawierać co najmniej jedną z nich. + + + Żądane acr_values (OIDC) + + + Żądanie claims (OIDC, JSON) + + + Odwołania do klas RequestedAuthnContext (SAML) + + + Wartości amr (OIDC) + + + Wartości acr (OIDC) + + + Wartości acrs (OIDC, kontekst uwierzytelniania) + + + Wartości AuthnContextClassRef (SAML) + + + Zapisz mapowanie + + + Usuń mapowanie + + + Zapisano mapowanie. Zacznie obowiązywać po udanym logowaniu testowym. + + + Usunięto mapowanie. MFA dostawcy tożsamości nie liczy się już w tym departamencie. + + + Nie można użyć mapowania: {0} + + + Zmiana mapowania wymaga niedawnej weryfikacji aplikacją uwierzytelniającą lub kluczem dostępu. MFA dostawcy tożsamości nie może zatwierdzić tej zmiany. + + + Mapowanie MFA dostawcy tożsamości + + + Przetestuj logowaniem + + + Test przekieruje Cię teraz do dostawcy tożsamości z prośbą o MFA. Gdy zwróci wartość liczoną przez mapowanie, ta wersja zacznie obowiązywać. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx index 55c1e1abe..a2a59f4a2 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Säkerhet och behörigheter - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Här kan du ställa in behörigheterna för din avdelning, till exempel vilka användare eller roller som får skapa larm eller vem som får lägga till och ta bort användare. Ändringar av behörigheterna börjar gälla vid nästa inloggning i Resgrids webbapplikation. - Audit Logs + Granskningsloggar + + + Filtrera efter granskningstyp + + + Alla granskningstyper + + + Sök efter användarnamn, användar- eller gransknings-ID, e-postadress, datum/tid eller granskningstyp. Sökning och sortering gäller inom den valda granskningstypen. + + + Sök i granskningsloggar: + + + Namn, ID, e-post, datum/tid eller typ + + + Tidsstämpel + + + Typ + + + Loggad av + + + Resultat + + + Meddelande + + + Söktermer + + + Åtgärder + + + Lyckad + + + Misslyckad + + + Visa + + + Visar _START_ till _END_ av _TOTAL_ poster + + + Visar 0 till 0 av 0 poster + + + (filtrerat från totalt _MAX_ poster) + + + Visa _MENU_ poster + + + Läser in... + + + Inga poster i granskningsloggen + + + Inga matchande poster i granskningsloggen hittades + + + Första + + + Sista + + + Nästa + + + Föregående + + + : aktivera för att sortera kolumnen stigande + + + : aktivera för att sortera kolumnen fallande + + + System + + + Okänd + + + Visa granskningslogg + + + Granskningslogg + + + Granskningslogg-ID: + + + Avdelnings-ID: + + + Granskningstyp: + + + Loggtyp-ID: + + + Typbeskrivning: + + + Resultat: + + + Loggad av: + + + Användar-ID: + + + Loggad (lokal tid): + + + Loggad (UTC): + + + IP-adress: + + + Servernamn: + + + Objekt-ID: + + + Objektets avdelnings-ID: + + + Användaragent: + + + Meddelande: + + + Data: + + + Inte registrerat + + + Avdelningsinställningar ändrade + + + Användare tillagd + + + Användare borttagen + + + Grupp tillagd + + + Grupp borttagen + + + Grupp ändrad + + + Enhet tillagd + + + Enhet borttagen + + + Enhet ändrad + + + Profil uppdaterad + + + Behörigheter ändrade + + + Prenumeration uppdaterad + + + Prenumeration skapad + + + Prenumeration avbruten + + + Faktureringsuppgifter för prenumeration uppdaterade + + + Larm återaktiverat + + + Användarkonto raderat + + + Tilläggsprenumeration modifierad + + + Radering av avdelning begärd + + + Begäran om radering av avdelning avbruten + + + Statiskt skift raderat + + + Statiskt skift uppdaterat + + + Anpassad status tillagd + + + Anpassad status borttagen + + + Anpassad status uppdaterad + + + Detalj för anpassad status uppdaterad + + + Larmtyp tillagd + + + Larmtyp redigerad + + + Larmtyp borttagen + + + Larmprioritet tillagd + + + Larmprioritet redigerad + + + Larmprioritet borttagen + + + Enhetstyp tillagd + + + Enhetstyp redigerad + + + Enhetstyp borttagen + + + Certifieringstyp tillagd + + + Certifieringstyp redigerad + + + Certifieringstyp borttagen + + + Dokumentkategori tillagd + + + Dokumentkategori redigerad + + + Dokumentkategori borttagen + + + Dokument tillagt + + + Dokument redigerat + + + Dokument borttaget + + + Anteckningskategori tillagd + + + Anteckningskategori redigerad + + + Anteckningskategori borttagen + + + Anteckning tillagd + + + Anteckning redigerad + + + Anteckning borttagen + + + Kontakt tillagd + + + Kontakt redigerad + + + Kontakt borttagen + + + Kontaktkategori tillagd + + + Kontaktkategori redigerad + + + Kontaktkategori borttagen + + + Anteckningstyp för kontakt tillagd + + + Anteckningstyp för kontakt redigerad + + + Anteckningstyp för kontakt borttagen + + + Arbetsflöde skapat + + + Arbetsflöde uppdaterat + + + Arbetsflöde raderat + + + Arbetsflödessteg tillagt + + + Arbetsflödessteg uppdaterat + + + Arbetsflödessteg raderat + + + Autentiseringsuppgift för arbetsflöde tillagd + + + Autentiseringsuppgift för arbetsflöde uppdaterad + + + Autentiseringsuppgift för arbetsflöde raderad + + + Kod för kontaktverifiering skickad + + + Kontaktverifiering bekräftad + + + Kontaktverifiering misslyckad + + + 2FA aktiverad + + + 2FA inaktiverad + + + 2FA-inloggning verifierad + + + Återställningskod för 2FA använd + + + Säkerhetskontroll med 2FA verifierad + + + SSO-konfiguration skapad + + + SSO-konfiguration uppdaterad + + + SSO-konfiguration raderad + + + SSO-inloggning lyckad + + + SSO-inloggning misslyckad + + + SSO-användare etablerad + + + SCIM-användare skapad + + + SCIM-användare uppdaterad + + + SCIM-användare avaktiverad + + + SCIM-användare raderad + + + SCIM-autentisering misslyckad + + + SCIM-användare återaktiverad + + + SCIM-grupper listade + + + SCIM-användare listade + + + SCIM-användare läst + + + SCIM-bärartoken etablerad + + + SCIM-bärartoken roterad + + + UDF-definition skapad + + + UDF-definition uppdaterad + + + UDF-definition raderad + + + UDF-fält tillagt + + + UDF-fält uppdaterat + + + UDF-fält borttaget + + + UDF-fältvärden sparade + + + Rutt skapad + + + Rutt uppdaterad + + + Rutt raderad + + + Rutt startad + + + Rutt slutförd + + + Rutt avbruten + + + Rutt pausad + + + Rutt återupptagen + + + Ruttstopp incheckat + + + Ruttstopp utcheckat + + + Ruttstopp överhoppat + + + Ruttavvikelse upptäckt + + + Ruttavvikelse kvitterad + + + Konfiguration för incheckningstimer skapad + + + Konfiguration för incheckningstimer uppdaterad + + + Konfiguration för incheckningstimer raderad + + + Åsidosättning för incheckningstimer skapad + + + Åsidosättning för incheckningstimer uppdaterad + + + Åsidosättning för incheckningstimer raderad + + + Incheckning utförd + + + Incheckningstimer aktiverad för larm + + + Incheckningstimer inaktiverad för larm + + + Incheckning till kalenderhändelse + + + Utcheckning från kalenderhändelse + + + Incheckningstider i kalender uppdaterade + + + Kalenderincheckning raderad + + + Administratörsincheckning i kalender + + + Loggpost skapad + + + Loggpost raderad + + + Kommunikationstest skapat + + + Kommunikationstest uppdaterat + + + Kommunikationstest raderat + + + Körning av kommunikationstest startad + + + Vädervarningskälla skapad + + + Vädervarningskälla uppdaterad + + + Vädervarningskälla raderad + + + Vädervarningskälla aktiverad + + + Vädervarningskälla inaktiverad + + + Vädervarningszon skapad + + + Vädervarningszon uppdaterad + + + Vädervarningszon raderad + + + Vädervarningszon aktiverad + + + Vädervarningszon inaktiverad + + + Inställningar för vädervarningar ändrade + + + Funktionsflagga ändrad + + + Åsidosättning av funktionsflagga ändrad + + + Spårningsenhet skapad + + + Spårningsenhet uppdaterad + + + Spårningsenhet inaktiverad + + + Spårningsenhet raderad + + + Autentiseringsuppgift för enhetsspårning skapad + + + Autentiseringsuppgift för enhetsspårning roterad + + + Autentiseringsuppgift för enhetsspårning återkallad + + + Radering av avdelning genomförd + + + Chattmeddelande raderat av moderator + + + Chattanvändare tystad + + + Tystning av chattanvändare hävd + + + Chattanvändare blockerad + + + Blockering av chattanvändare hävd + + + Chattkanal låst + + + Chattkanal upplåst + + + Chattkanal arkiverad + + + Chattflaggning löst + + + Chattinställningar ändrade + + + Chattexport begärd + + + Chattexport hämtad + + + Modereringsrapport inskickad + + + Modereringsärende återöppnat + + + Modereringsärende slutfört + + + Bevismaterial för moderering hämtat + + + Lösenord återställt av administratör + + + Inloggningssessioner för användare återkallade + + + Undantag från säkerhetskontroll för dataskydd ändrade + + + Insatsplan för kontakt tillagd + + + Insatsplan för kontakt uppdaterad + + + Insatsplan för kontakt borttagen + + + Kontaktbilaga tillagd + + + Kontaktbilaga borttagen + + + Checklistedefinition tillagd + + + Checklistedefinition uppdaterad + + + Checklistedefinition publicerad + + + Checklistedefinition avvecklad + + + Checklistedefinition borttagen + + + Genomförande av checklista startat + + + Checklisteförlopp sparat + + + Genomförande av checklista inskickat + + + Checklista intygad av vittne + + + Checklistefil tillagd + + + Checklistefil borttagen + + + Kontrollschema tillagt + + + Kontrollschema uppdaterat + + + Planerad kontroll missad + + + Planerad kontroll överhoppad + + + Påminnelseinställningar för checklistor uppdaterade + + + Arbetsorder ändrad + + + Inventarium ändrat + + + Faktureringsprofil ändrad + + + Prislista ändrad + + + Faktura skapad + + + Faktura uppdaterad + + + Faktura skickad + + + Faktura makulerad + + + Fakturabetalning registrerad + + + Faktureringsidentitet för avdelning ändrad + + + Betalningsanslutning ansluten + + + Betalningsanslutning frånkopplad + + + Betalningsanslutning återkallad + + + Betalningsanslutning kräver åtgärd + + + Betalningsbegäran för faktura skapad + + + Fakturabetalning återbetald + + + Fakturabetalning bestriden + + + Betalningswebhook avvisad + + + Betalningsbegäran för faktura misslyckad + + + Betalningsbegäran för faktura utgången + + + Certifiering tillagd + + + Certifiering uppdaterad + + + Certifiering borttagen + + + Certifieringsstatus ändrad + + + Certifiering verifierad + + + Certifieringspoäng tillagda + + + Certifieringspoäng borttagna + + + Certifieringskrav för roll ändrat + + + Certifieringsinställningar för avdelning ändrade + + + Rollmedlem tillagd + + + Rollmedlem borttagen + + + Rollmedlem borttagen på grund av certifiering + + + Enhetscertifiering tillagd + + + Enhetscertifiering uppdaterad + + + Enhetscertifiering borttagen + + + Enhetscertifieringens status ändrad + + + Insats skapad + + + Insats uppdaterad + + + Insatsstatus ändrad + + + Insatsbemanning ändrad + + + Insatsutrustning ändrad + + + Insatsbilaga tillagd + + + Insatsbilaga borttagen + + + Tidrapport skapad + + + Tidrapport uppdaterad + + + Tidrapport inskickad + + + Tidrapport godkänd + + + Tidrapport makulerad + + + Insatsutlägg tillagt + + + Insatsutlägg uppdaterat + + + Insatsutlägg borttaget + + + Taxetabell skapad + + + Taxetabell uppdaterad + + + Taxetabell raderad + + + Taxetabellpost ändrad + + + Taxetillägg ändrat + + + Serviceavtal skapat + + + Serviceavtal uppdaterat + + + Status för serviceavtal ändrad + + + Serviceavtal raderat + + + Efterlevnadsdokument tillagt + + + Efterlevnadsdokument uppdaterat + + + Efterlevnadsdokument borttaget + + + Anbud skapat + + + Anbud uppdaterat + + + Anbud skickat + + + Anbud antaget + + + Anbud avböjt + + + Anbud tillbakadraget + + + Anbud utgånget + + + Anbud omvandlat + + + Anbud raderat + + + Tidrapport fakturerad + + + Insatsfaktura genererad + + + Cal OES MARS-myndighetsprofil ändrad + + + Cal OES MARS-resursprofil ändrad + + + Cal OES MARS-taxprofil ändrad + + + Cal OES MARS-taxutkast sammanställt + + + Cal OES MARS-taxa granskad + + + Cal OES MARS-avtal ändrat + + + Cal OES MARS-avtal observerat + + + Cal OES MARS-ärende förberett + + + Cal OES MARS-ärende validerat + + + Cal OES MARS-ersättning beräknad + + + Cal OES MARS-ärende öppnat för överlämning + + + Extern Cal OES MARS-status observerad + + + Cal OES MARS-faktura godkänd + + + Cal OES MARS-faktura avvisad + + + Cal OES MARS-betalning avstämd + + + Cal OES MARS-ärende raderat + + + Arbetsgivarprofil ändrad + + + Arbetsställe ändrat + + + Anställning ändrad + + + Personalersättning ändrad + + + Årliga lönefakta importerade + + + Demografiska uppgifter för lönedata ändrade + + + Lönedatarapport skapad + + + Lönedatarapport validerad + + + Lönedatarapport fryst + + + Lönedatarapport exporterad + + + Lönedatarapport markerad som certifierad + + + Lönedatarapport korrigerad + + + Resurskostnadsprofil ändrad + + + Resursanvändning ändrad + + + Fältkostnadskörning skapad + + + Fältkostnadskörning fryst + + + Användare återaktiverad + + + Avdelningskonfiguration ändrad + + + Admin Assist-granskning ändrad + + + Åtkomst till Admin Assist-diagnostik + + + Inställningar för AI-larmhantering uppdaterade + + + Åtkomst till Admin Assist-ändringsplan + + + Säkerhetspolicy ändrad - Permission + Behörighet Anteckning - Value + Värde Roller - Two-Factor Authentication (2FA) Enforcement + Krav på tvåfaktorsautentisering (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Kräv 2FA med autentiseringsapp för administratörer. När detta är aktiverat skickas administratörer som inte har konfigurerat 2FA vidare till registrering innan de kan använda administrativa funktioner. - Require 2FA for Admins + Kräv 2FA för administratörer - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Styr vilka användare på administratörsnivå som måste registrera sig för 2FA med autentiseringsapp (Google Authenticator, Microsoft Authenticator, Authy m.fl.). - Disabled (no requirement) + Inaktiverat (inget krav) - Department Admins + Managing User + Avdelningsadministratörer + ansvarig användare - Department Admins + Managing User + Group Admins + Avdelningsadministratörer + ansvarig användare + gruppadministratörer - 2FA enforcement setting saved. + Inställningen för 2FA-krav har sparats. - Failed to save 2FA enforcement setting. + Det gick inte att spara inställningen för 2FA-krav. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Det går inte att aktivera 2FA-kravet: avdelningens ansvariga användare har inte aktiverat tvåfaktorsautentisering på sitt konto. Den ansvariga användaren måste aktivera 2FA innan inställningen kan slås på. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Det går inte att aktivera 2FA-kravet: du har inte aktiverat tvåfaktorsautentisering på ditt eget konto. Du måste aktivera 2FA innan du kan kräva det av andra. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Det går inte att aktivera 2FA-kravet: varken du eller den ansvariga användaren har aktiverat tvåfaktorsautentisering. Båda måste aktivera 2FA innan inställningen kan slås på. - This setting is locked until the above conditions are met. + Inställningen är låst tills villkoren ovan är uppfyllda. - Permission + Behörighet Anteckning - Selection + Val - Group Only + Endast grupp Roller - No Roles + Inga roller - N/A + Ej tillämpligt + + + Alla + + + Avdelningsadministratörer + + + Avdelnings- och gruppadministratörer + + + Avdelningsadministratörer och valda roller + + + Avdelnings- och gruppadministratörer samt valda roller - Security and Permissions + Säkerhet och behörigheter + + + Hem - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Här kan du ställa in behörigheterna för din avdelning, till exempel vilka användare eller roller som får skapa larm eller vem som får lägga till och ta bort användare. Ändringar av behörigheterna börjar gälla vid nästa inloggning i Resgrids webbapplikation. - Who can Add Users + Vem kan lägga till användare - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Det här alternativet avgör vem som får lägga till användare/personal i avdelningen. Som standard kan bara avdelningsadministratörer (och den ansvariga medlemmen) lägga till användare. Gruppadministratörer kan dock också få lägga till användare (begränsat till den grupp de administrerar). - Who can Remove Users + Vem kan ta bort användare - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Det här alternativet avgör vem som får ta bort användare/personal från avdelningen. Som standard kan bara avdelningsadministratörer (och den ansvariga medlemmen) ta bort användare. Gruppadministratörer kan dock också få ta bort användare (begränsat till användarna i den grupp de administrerar). - Who can Create Calls + Vem kan skapa larm - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Det här alternativet avgör vem som får skapa larm manuellt i Resgrid. Som standard kan alla skapa larm. - Who can Delete Calls + Vem kan ta bort larm - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Det här alternativet avgör vem som får ta bort larm i Resgrid. Som standard kan alla ta bort larm. - Who can Close Calls + Vem kan avsluta larm - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Det här alternativet avgör vem som får avsluta larm i Resgrid. Som standard kan alla avsluta larm. - Who can Add Data To Calls + Vem kan lägga till data i larm - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Det här alternativet avgör vem som får lägga till data, till exempel bilder, anteckningar och filer, i larm i Resgrid. Som standard kan alla lägga till data i larm. - Who can Create Trainings + Vem kan skapa utbildningar - This option determines who can create trainings. By default only Department Admins can create trainings. + Det här alternativet avgör vem som får skapa utbildningar. Som standard kan bara avdelningsadministratörer skapa utbildningar. - Who can Add Documents + Vem kan lägga till dokument - This option determines who can add documents. By default Everyone can add documents. + Det här alternativet avgör vem som får lägga till dokument. Som standard kan alla lägga till dokument. - Who can Create Calendar Entries + Vem kan skapa kalenderposter - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Det här alternativet avgör vem som får skapa kalenderposter. Som standard kan alla skapa kalenderposter. - Who can Create Notes + Vem kan skapa anteckningar - This option determines who can create notes. By default Everyone can create notes. + Det här alternativet avgör vem som får skapa anteckningar. Som standard kan alla skapa anteckningar. - Who can Add Log Entries + Vem kan lägga till loggposter - This option determines who can add log entries. By default Everyone can add log entries. + Det här alternativet avgör vem som får lägga till loggposter. Som standard kan alla lägga till loggposter. - Who can Create Shifts + Vem kan skapa skift - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Det här alternativet avgör vem som får skapa och redigera skift. Som standard kan bara avdelningsadministratörer skapa och redigera skift. - Who can View Personal Info + Vem kan se personuppgifter - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Det här alternativet avgör vem som får se personuppgifter (PII) om personalen i systemet, till exempel e-postadress och telefonnummer. Som standard kan alla se den här informationen. - Who can Adjust Inventory + Vem kan justera lager - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Det här alternativet avgör vem som får justera lagernivåer i systemet. Som standard kan alla justera lager. - Who can see the Location of Personnel + Vem kan se personalens position - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Det här alternativet avgör vem som kan se personalens position på kartorna. Markera alternativet Endast grupp för att begränsa det till gruppadministratörer och roller inom en grupp. - Who can see the Location of Units + Vem kan se enheternas position - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Det här alternativet avgör vem som kan se enheternas position på kartorna. Markera alternativet Endast grupp för att begränsa det till gruppadministratörer och roller inom en grupp. - Who can send messages + Vem kan skicka meddelanden - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Det här alternativet avgör vem som får skapa och skicka meddelanden (intern post i systemet). Som standard kan alla skapa och skicka meddelanden. - Who can view users + Vem kan se användare - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + Som standard kan alla användare se alla andra användare i systemet. Med det här alternativet kan du begränsa vem som kan se användare i systemet. - Who can view units + Vem kan se enheter - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + Som standard kan alla användare se alla enheter i systemet. Med det här alternativet kan du begränsa vem som kan se enheter i systemet. - Who can view Contacts + Vem kan se kontakter - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + Som standard kan alla användare se alla kontakter i systemet. Med det här alternativet kan du begränsa vem som kan se kontakter i systemet. En användare som inte kan se kontakter kan inte heller lägga till dem i ett larm. - Who can edit or create Contacts + Vem kan redigera eller skapa kontakter - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + Som standard kan alla användare skapa och redigera kontakter i systemet. Med det här alternativet kan du begränsa vem som kan skapa eller redigera kontakter i systemet. - Who can delete Contacts + Vem kan ta bort kontakter - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + Som standard kan alla användare ta bort kontakter i systemet. Med det här alternativet kan du begränsa vem som kan ta bort kontakter i systemet. - Who can Create/Edit Workflows + Vem kan skapa/redigera arbetsflöden - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Det här alternativet avgör vem som får skapa, redigera och ta bort arbetsflöden och arbetsflödessteg. Som standard kan bara avdelningsadministratörer hantera arbetsflöden. - Who can Manage Workflow Credentials + Vem kan hantera autentiseringsuppgifter för arbetsflöden - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Det här alternativet avgör vem som får skapa, redigera och ta bort krypterade autentiseringsuppgifter som används av arbetsflödesåtgärder (t.ex. SMTP-lösenord och API-nycklar). Som standard kan bara avdelningsadministratörer hantera autentiseringsuppgifter. - Who can View Workflow Runs + Vem kan se arbetsflödeskörningar - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Det här alternativet avgör vem som får se körhistorik, körloggar och hälsoöversikter för arbetsflöden. Som standard kan bara avdelningsadministratörer se arbetsflödeskörningar. - Single Sign-On (SSO) & SCIM + Enkel inloggning (SSO) och SCIM - Single Sign-On & Identity Provisioning + Enkel inloggning och identitetsetablering SSO / SCIM - Security Policy + Säkerhetspolicy - Add OIDC Config + Lägg till OIDC-konfiguration - Add SAML 2.0 Config + Lägg till SAML 2.0-konfiguration - What is SSO & SCIM? + Vad är SSO och SCIM? - Single Sign-On (SSO) + Enkel inloggning (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Låt avdelningens medlemmar logga in med sitt befintliga jobbkonto (Microsoft Entra ID, Okta, Google Workspace m.fl.) i stället för ett separat lösenord för Resgrid. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC — modernt protokoll, idealiskt för Entra, Okta, Auth0 och Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 — brett stöd hos identitetsleverantörer inom offentlig sektor och näringsliv - SCIM 2.0 Provisioning + SCIM 2.0-etablering - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Synkronisera användare automatiskt från din identitetsleverantör. När du lägger till eller tar bort personal i din användarkatalog återspeglar Resgrid ändringarna automatiskt — inga manuella steg för att bjuda in eller ta bort. - Auto-create new members when added in your IdP + Skapa nya medlemmar automatiskt när de läggs till i din IdP - Disable/remove members when deprovisioned + Inaktivera/ta bort medlemmar när de avetableras - Keep names & email addresses in sync + Håll namn och e-postadresser synkroniserade - Security Policy + Säkerhetspolicy - Enforce department-wide compliance controls alongside SSO: + Tillämpa avdelningsövergripande efterlevnadskontroller tillsammans med SSO: - Mandate MFA for all members + Kräv MFA för alla medlemmar - Restrict login to SSO only (disable passwords) + Begränsa inloggning till endast SSO (inaktivera lösenord) - Limit logins to specific IP CIDR ranges + Begränsa inloggningar till specifika IP-intervall (CIDR) - Set password expiration & complexity rules + Ange regler för lösenordens giltighetstid och komplexitet - Classify data level (Unclassified / CUI / Confidential) + Klassificera datanivå (Oklassificerad / CUI / Konfidentiell) - Mobile App Discovery URL + Identifierings-URL för mobilappen - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + Resgrids mobilapp använder den här URL:en för att hitta dina SSO-inställningar innan inloggningsskärmen visas. Dela den med ditt mobilteam vid behov. - Copy + Kopiera - Copied to clipboard! + Kopierat till urklipp! - SSO Configurations + SSO-konfigurationer - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Inga SSO-konfigurationer ännu. Använd knapparna ovan för att lägga till OIDC eller SAML 2.0. - Provider + Leverantör - Identifier / Endpoint + Identifierare / slutpunkt Status - Local Login + Lokal inloggning - Auto-Provision + Automatisk etablering SCIM - Created + Skapad Åtgärder - Enabled + Aktiverad - Disabled + Inaktiverad Ja - SSO Only + Endast SSO - On + På - Off + Av Aktiv - No Token + Ingen token - Off + Av Redigera @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Bekräfta borttagning - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Vill du verkligen ta bort SSO-konfigurationen {0}? Det går inte att ångra. + + + Ogiltig leverantörstyp. + + + Det finns redan en SSO-konfiguration för {0}. Använd Redigera för att ändra den. + + + OIDC-klient-ID krävs. + + + OIDC-auktoriteten måste vara en giltig HTTPS-URL. + + + IdP:ns signeringscertifikat krävs för att validera SAML-intyg. + + + Identitetsleverantörens URL för enkel inloggning måste vara en giltig HTTPS-URL. + + + SSO-konfigurationen för {0} har skapats. + + + SSO-konfigurationen har uppdaterats. + + + SSO-konfigurationen för {0} har tagits bort. Avbryt @@ -453,10 +1569,10 @@ Ta bort - New SSO Configuration + Ny SSO-konfiguration - Edit SSO Configuration + Redigera SSO-konfiguration Ny @@ -465,226 +1581,256 @@ Redigera - Step 1 + Steg 1 - Provider & Basic Settings + Leverantör och grundinställningar - Identity Provider Protocol + Identitetsleverantörens protokoll + + + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google och Auth0 + + + SAML 2.0 — de flesta identitetsleverantörer inom näringsliv och offentlig sektor - Enable this SSO configuration + Aktivera den här SSO-konfigurationen - Only one configuration per provider type is active at a time. + Endast en konfiguration per leverantörstyp kan vara aktiv åt gången. - Allow local password login + Tillåt inloggning med lokalt lösenord - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + När detta är markerat kan användare fortfarande logga in med användarnamn och lösenord utöver SSO. Avmarkera detta och aktivera Kräv SSO i säkerhetspolicyn för att tvinga fram inloggning endast via SSO. - Auto-provision new users + Etablera nya användare automatiskt - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Skapa automatiskt ett Resgrid-konto när en användare autentiseras via SSO för första gången och ingen matchande e-postadress hittas. Lämna avstängt för att kräva en manuell inbjudan först. - Enable SCIM 2.0 provisioning + Aktivera SCIM 2.0-etablering - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Låter din IdP automatiskt skapa, uppdatera och inaktivera medlemmar. När du har sparat går du till sidan SCIM-inställningar för att generera en bärartoken. - Default Rank for Auto-Provisioned Users + Standardgrad för automatiskt etablerade användare + + + (Ingen standardgrad) - Optional. Applied only when Auto-Provision creates a new member. + Valfritt. Används bara när automatisk etablering skapar en ny medlem. - Step 2 — OIDC + Steg 2 — OIDC - OpenID Connect Settings + Inställningar för OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Här hittar du värdena: Registrera Resgrid i din IdP som en publik klient (PKCE, ingen klienthemlighet krävs för mobil) eller som en webbapp (med hemlighet för flöden på serversidan). Kopiera klient-ID och utfärdar-URL från det registrerade programmet. - Authority / Issuer URL + Auktoritet / utfärdar-URL https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Exempel: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com - Client ID + Klient-ID xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + Den publika klientidentifierare som din IdP har tilldelat. Säker att visa — inte en hemlighet. - Client Secret + Klienthemlighet - Secret stored + Hemlighet lagrad - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Krypteras i vila med din avdelningsspecifika nyckel. Lämna tomt om du använder PKCE (mobil/SPA — ingen hemlighet behövs). - Leave blank to keep the existing secret unchanged. + Lämna tomt för att behålla den befintliga hemligheten oförändrad. - Step 2 — SAML 2.0 + Steg 2 — SAML 2.0 - SAML 2.0 Settings + Inställningar för SAML 2.0 - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Här hittar du värdena: Skapa ett nytt SAML-program i din IdP. Ange SP-entitets-ID och ACS-URL som visas nedan i din IdP och klistra sedan in IdP:ns metadata-URL eller certifikat här. - SP Entity ID (enter this into your IdP) + SP-entitets-ID (ange detta i din IdP) - ACS URL (Assertion Consumer Service — enter this into your IdP) + ACS-URL (Assertion Consumer Service — ange detta i din IdP) - This URL uses an encrypted department token — never exposes your plain department code. + Den här URL:en använder en krypterad avdelningstoken — din avdelningskod exponeras aldrig i klartext. - SP Entity ID (editable) + SP-entitets-ID (redigerbart) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Måste exakt matcha det du angav i IdP:n. - IdP Metadata URL + IdP:ns metadata-URL https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid hämtar regelbundet den här URL:en för att hålla IdP-certifikaten aktuella. - ACS URL (stored) + ACS-URL (lagrad) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Fylls i automatiskt från den genererade URL:en ovan. Ändra bara om du har konfigurerat en annan ACS-URL i din IdP. + + + Omdirigerings-URI för inloggning som startas från Resgrid (ange den hos din identitetsleverantör) + + + Lägg till den bredvid de omdirigerings-URI:er du redan har registrerat; äldre versioner av Resgrid-apparna fortsätter använda sina. + + + Omdirigerings-URI:er för inloggning i Resgrid-apparna (ange var och en hos din identitetsleverantör) + + + Varje app, och varje apps webbversion, återvänder till sin egen adress, så registrera alla. De används av äldre versioner av apparna och av appar vars inloggning inte startas från Resgrid. + + + {0} (webb) + + + Identitetsleverantörens URL för enkel inloggning + + + Identitetsleverantörens SAML-inloggningsadress (HTTP-Redirect-bindning). Behövs för inloggningar som startas från Resgrid-apparna och webbplatsen. - IdP Signing Certificate (PEM) + IdP:ns signeringscertifikat (PEM) - Certificate stored + Certifikat lagrat - Encrypted at rest. Leave blank to keep the existing certificate. + Krypteras i vila. Lämna tomt för att behålla det befintliga certifikatet. - SP Signing Key (PEM, optional) + SP-signeringsnyckel (PEM, valfritt) - Key stored + Nyckel lagrad - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Krypteras i vila. Lämna tomt om din IdP inte kräver signerade förfrågningar. - Step 3 + Steg 3 - Attribute Mapping + Attributmappning - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Mappa de anspråksnamn (claims) som din IdP skickar till användarfälten i Resgrid. Lämna tomt för att automatiskt använda standard-URI:erna för anspråk. Värdet är anspråkets namn så som din IdP skickar det. - Attribute Mapping JSON + Attributmappning (JSON) - Must be valid JSON. Use the quick-fill buttons on the right. + Måste vara giltig JSON. Använd snabbifyllningsknapparna till höger. - Quick-fill presets + Förinställningar för snabbifyllning - Click to auto-fill the mapping JSON with known defaults for your IdP. + Klicka för att automatiskt fylla i mappnings-JSON med kända standardvärden för din IdP. - Supported Resgrid field keys + Fältnycklar i Resgrid som stöds - Key + Nyckel Beskrivning - User's email address + Användarens e-postadress - Given / first name + Tilltalsnamn / förnamn - Surname / family name + Efternamn / släktnamn - Unique IdP subject (for account linking) + Unik subjektidentifierare från IdP:n (för kontokoppling) - Create Configuration + Skapa konfiguration - Save Changes + Spara ändringar Avbryt - SCIM 2.0 Setup + SCIM 2.0-inställningar - SCIM 2.0 Provisioning Setup + Inställning av SCIM 2.0-etablering - SCIM Setup + SCIM-inställningar - Back to SSO + Tillbaka till SSO - Important — Copy your SCIM Bearer Token Now + Viktigt — kopiera din SCIM-bärartoken nu - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Denna token visas bara en gång och kan inte hämtas igen. Kopiera den direkt och klistra in den i din identitetsleverantörs SCIM-konfiguration. Om du tappar bort den klickar du på Rotera SCIM-token för att generera en ny. - Copy Token + Kopiera token - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Denna token lagras krypterad i Resgrid-databasen med din avdelningsspecifika krypteringsnyckel. - SCIM Status + SCIM-status - Provider + Leverantör - SCIM Enabled + SCIM aktiverat Ja @@ -693,61 +1839,61 @@ Nej - Bearer Token + Bärartoken - Configured + Konfigurerad - Not Set + Inte angiven - Rotate SCIM Token + Rotera SCIM-token - Generate SCIM Token + Generera SCIM-token - Rotating generates a new token and invalidates the old one. + Rotation genererar en ny token och gör den gamla ogiltig. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Är du säker? Befintliga SCIM-integrationer slutar fungera tills du uppdaterar token i din IdP. - SCIM Connector Settings + Inställningar för SCIM-anslutning - Enter these values into your identity provider's SCIM provisioning configuration. + Ange de här värdena i SCIM-etableringskonfigurationen hos din identitetsleverantör. - Setting + Inställning - Value + Värde - SCIM Connector Base URL + Bas-URL för SCIM-anslutning - Authentication Method + Autentiseringsmetod - Authorization Header + Auktoriseringsrubrik - Custom Header Name + Namn på anpassad rubrik - Custom Header Value + Värde för anpassad rubrik - Supported Resources + Resurser som stöds - Supported Update Method + Uppdateringsmetod som stöds - Step-by-Step Setup Guide + Steg-för-steg-guide Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Annan / generisk - Any SCIM 2.0 compatible client can be configured with the following: + Alla SCIM 2.0-kompatibla klienter kan konfigureras med följande: - Resgrid SCIM field mapping + Resgrids SCIM-fältmappning Parameter - SCIM field + SCIM-fält - Resgrid effect + Effekt i Resgrid @@ -804,7 +1950,7 @@ Generera eller rotera Bearer Token som din identitetsleverantör använder för att autentisera SCIM-förfrågningar. Token visas bara en gång — kopiera den innan du stänger den här dialogen. Etablera token Rotera token - SCIM Bearer Token + SCIM-bärartoken Denna token visas bara en gång och kan inte hämtas igen. Kopiera den nu och klistra in den i din identitetsleverantörs SCIM-konfiguration. Rotation av token gör den aktuella token omedelbart ogiltig. All aktiv SCIM-synkronisering misslyckas tills du uppdaterar token i din IdP. Fortsätta? Detta genererar en ny SCIM-bärartoken för den här konfigurationen. Kopiera och spara den säkert – den visas bara en gång. Fortsätta? @@ -814,112 +1960,136 @@ För steg-för-steg-instruktioner specifika för din IdP (Okta, Microsoft Entra ID, Google Workspace) och tokenhantering, besök SCIM-inställningssidan för varje konfiguration. - Department Security Policy + Avdelningens säkerhetspolicy - Department Security Policy + Avdelningens säkerhetspolicy - Security Policy + Säkerhetspolicy - Back to SSO + Tillbaka till SSO - Security policy saved successfully. + Säkerhetspolicyn har sparats. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Ingen aktiv SSO-konfiguration. Du kan konfigurera lösenordspolicyer nedan, men Kräv SSO kan inte aktiveras förrän du har en aktiv SSO-konfiguration. - Configure SSO → + Konfigurera SSO → - Authentication Controls + Autentiseringskontroller - Require MFA for all members + Kräv MFA för alla medlemmar - Members who have not enrolled in MFA will be prompted to do so on next login. + Medlemmar som inte har registrerat sig för MFA uppmanas att göra det vid nästa inloggning. - Require SSO — disable password login + Kräv SSO — inaktivera lösenordsinloggning - Requires active SSO config + Kräver en aktiv SSO-konfiguration - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Varning: Om du aktiverar detta blockeras alla inloggningar med användarnamn och lösenord. Se till att minst en administratör har testat SSO-inloggningen med lyckat resultat innan du aktiverar det. - Session Timeout (minutes) + Sessionens tidsgräns (minuter) - 0 = use system default. 480 = 8 hours. + 0 = använd systemets standardvärde. 480 = 8 timmar. - Max Concurrent Sessions per User + Max samtidiga sessioner per användare - 0 = unlimited. Government environments typically set 1. + 0 = obegränsat. Myndighetsmiljöer anger vanligtvis 1. - Allowed IP Ranges (CIDR) + Tillåtna IP-intervall (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Ett CIDR-block per rad, eller kommaseparerade. Tomt = tillåt alla. Inloggningar utanför dessa intervall nekas. - Data Classification Level + Dataklassificeringsnivå + + + Oklassificerad + + + CUI - Kontrollerad oklassificerad information + + + Konfidentiell - Used for compliance reporting and audit logs. + Används för efterlevnadsrapportering och granskningsloggar. - Password Policy + Lösenordspolicy - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + Lösenordspolicyer gäller endast lokala inloggningar (utan SSO). Om du aktiverar Kräv SSO ovan har de här inställningarna ingen effekt. - Password Expiration (days) + Lösenordets giltighetstid (dagar) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = lösenord upphör aldrig att gälla. 90 är vanligt i CUI-miljöer. - Minimum Password Length + Minsta lösenordslängd - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Minst 8 (systemets standardvärde). Avdelningens policy kan bara höja värdet. NIST rekommenderar minst 12; CUI kräver minst 14. + + + Systemkrav på lösenordskomplexitet + + + Alla Resgrid-konton måste uppfylla följande standard för lösenordskomplexitet. Det går inte att inaktivera med avdelningens policy. + + + Minst 8 tecken (eller avdelningens minsta längd ovan) + + + Minst en siffra + + + Minst en stor och en liten bokstav - Require password complexity + Kräv lösenordskomplexitet - Enforces at least one uppercase letter, one digit, and one special character. + Kräver minst en stor bokstav, en siffra och ett specialtecken. - Quick presets + Snabbförinställningar - Government / CUI + Myndighet / CUI - Standard Enterprise + Standard för företag Minimal - Save Security Policy + Spara säkerhetspolicy Avbryt - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Det går inte att aktivera inloggning endast via SSO: det finns ingen aktiv SSO-konfiguration. Skapa och aktivera en SSO-konfiguration först. Lösenordet får inte vara tomt. Lösenordet måste innehålla minst en siffra. @@ -927,32 +2097,34 @@ Lösenordet måste innehålla minst en liten bokstav. Lösenordet måste vara minst {0} tecken långt. Minsta lösenordslängd kan inte vara kortare än systemets standardvärde på 8 tecken. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. - Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Ta bort loggposter + Vem i din avdelning som får ta bort loggposter + Använda kalendersynkronisering + Styr vem som får aktivera och använda URL:er för kalenderprenumeration för att synkronisera kalenderhändelser från Resgrid till externa kalenderprogram. + Inloggning i Dispatch-appen + Styr vem som får logga in i Dispatch-appen. Dispatch visar privat kommunikation från ledning, enheter och insatspersonal för varje händelse, så begränsa detta om inte alla dina medlemmar är larmoperatörer. + Inloggning i Command-appen + Styr vem som får agera som insatsledare: logga in i IC-appen, upprätta insatsledning för ett larm och visa ledningstavlor. Om du begränsar detta till något annat än Alla kan de personer du väljer också hjälpa till med valfri ledningstavla (tilldela och flytta resurser, köra timers och personalredovisning) utan att ha en ICS-befattning på den — användbart för att låta larmoperatörer hjälpa till i Dispatch-appen. Så länge inställningen är Alla är åtgärder på tavlan begränsade till insatsledaren och tilldelade ICS-roller. + Avancerat dataskydd + De här behörigheterna styr vem som får arbeta med krypterade (skyddade) data när tillägget Avancerat dataskydd är aktivt. Varje visning eller redigering kräver dessutom en nylig tvåfaktorsverifiering; de här inställningarna avgör vem som ens får försöka. Till skillnad från de flesta Resgrid-behörigheter får värden som inte har ställts in det visade restriktiva valet som standard. + Hantera inställningar för dataskydd + Vem som får ändra inställningar för Avancerat dataskydd, till exempel verifieringsfönstret och alternativ för aviseringarnas innehåll. Köp, registrering och uppsägning är alltid förbehållna avdelningens ansvariga medlem. + Visa skyddade larmdata + Vem som får visa skyddade larmfält (art, adress, kontaktuppgifter, anteckningar) efter tvåfaktorsverifiering. Standard är Alla eftersom insatspersonal måste kunna läsa ett larm. + Redigera skyddade larmdata + Vem som får redigera skyddade larmfält efter tvåfaktorsverifiering. Standard är Alla för att matcha det vanliga arbetsflödet för larm. + Visa skyddade personaldata + Vem som får visa skyddad personalinformation (anställnings-ID, nödkontakter) efter tvåfaktorsverifiering. Standard är avdelningsadministratörer. + Visa skyddade kontaktdata + Vem som får visa skyddade kontaktuppgifter (namn, telefonnummer, id-handlingar, platser) efter tvåfaktorsverifiering. Standard är avdelningsadministratörer. + Visa skyddade operativa data + Vem som får visa skyddat operativt innehåll (loggar, formulärinskick, anteckningar och bilagor från insatsledningen) efter tvåfaktorsverifiering. Standard är avdelnings- och gruppadministratörer. + Exportera skyddade data + Vem som får exportera data som innehåller skyddade fält. Exporterade data lämnar Resgrids skydd, så varje export granskningsloggas separat. Standard är avdelningsadministratörer; Alla erbjuds medvetet inte. + Konfigurera leverans av skyddade data + Vem som får ändra hur skyddat innehåll lämnar Resgrid via push, sms, e-post och röst. Standard är avdelningsadministratörer; Alla erbjuds medvetet inte. + Nödåtkomst (break-glass) + Vem som får använda den granskningsloggade nödåtkomsten till skyddade data. Den fungerar bara om nödåtkomst är aktiverad i avdelningens skyddspolicy, kräver en registrerad anledning och meddelar avdelningen. Standard är avdelningsadministratörer; Alla erbjuds medvetet inte. Rapporter Dessa behörigheter styr modulen Rapporter, efterföljaren till Loggar. Som för de flesta Resgrid-behörigheter gäller det visade standardvärdet för en rad som inte ställts in, och det motsvarar hur Loggar fungerar i dag. När Rapporter aktiveras för din avdelning kopieras även inställningarna Skapa logg och Ta bort logg till motsvarande rader, om du inte redan har ställt in dem här. Rapporter är ännu inte aktiverat för den här avdelningen. Du kan förbereda inställningarna nu; de börjar gälla när Rapporter aktiveras. @@ -1012,4 +2184,190 @@ Styr vem som får flytta inventarier mellan lagerplatser. Som standard har avdelningsadministratörer behörighet. + + Metoder för andra faktorn + + + Välj vilka verifieringsmetoder som räknas som multifaktorautentisering i den här organisationen. Koder från autentiseringsappar (TOTP) godtas alltid, så att stänga av en metod låser aldrig ute någon; medlemmar som använde den ombeds verifiera igen. + + + Endast organisationens ansvariga medlem kan ändra de här inställningarna. + + + Nycklar (passkeys) är ännu inte tillgängliga i det här systemet. Valen gäller när de blir det. + + + Inte tillgängligt ännu + + + Godta nycklar för inloggning och säkerhetskontroller + + + En nyckel som registrerats i samma app räknas som MFA för inloggning, byte av organisation och känsliga åtgärder. + + + Godta nycklar för skyddade data + + + En nyckel räknas som MFA för att visa och redigera skyddade data. En ändring avslutar pågående åtkomst till skyddade data, så medlemmarna verifierar igen. + + + Godta godkännande från Responder-appen + + + Där nycklar godtas kan en medlem godkänna en inloggning eller en begäran om skyddade data med nyckeln i sin Responder-app. Används aldrig för säkerhetsändringar. En ändring avslutar pågående åtkomst till skyddade data. + + + Godta din identitetsleverantörs MFA för inloggning och säkerhetskontroller + + + Kräver en testad MFA-mappning i din SSO-konfiguration. + + + Godta din identitetsleverantörs MFA för skyddade data + + + Kräver en testad MFA-mappning i din SSO-konfiguration. En ändring avslutar pågående åtkomst till skyddade data. + + + Din SSO-konfiguration har ingen testad MFA-mappning. Spara en mappning och slutför testet innan du godtar identitetsleverantörens MFA. + + + Verifiera först med din autentiseringsapp eller en nyckel för att godta identitetsleverantörens MFA. Identitetsleverantörens MFA kan inte godkänna den här ändringen. + + + Låt en nylig verifiering vid inloggning öppna skyddade data + + + Medlemmar som verifierade MFA vid inloggningen behöver inte verifiera igen för att visa skyddade data inom verifieringsfönstret. En ändring avslutar pågående åtkomst till skyddade data. + + + Låt upplåsning av en delad enhet öppna skyddade data + + + På delade fordonsplattor och arbetsstationer räknas operatörens nyliga upplåsningsverifiering för att visa skyddade data. En ändring avslutar pågående åtkomst till skyddade data. + + + Delade enheter i fordon och på arbetsstationer + + + Delade fordonsplattor och larmcentralsarbetsstationer låses när ingen använder dem och avslutas med passet. Operatörer låser upp med sin egen autentiseringsapp, nyckel eller Responder-godkännande, aldrig med ett lösenord som sparats på enheten. Ett striktare värde här gäller även sessioner som redan pågår. + + + Endast organisationens ansvariga medlem kan ändra policyn för delade enheter. + + + Läget för delade enheter är ännu inte tillgängligt i den här installationen. Värdena sparas, och läget kan inte krävas för en annan app förrän det är det. + + + Lås efter så här många minuter utan aktivitet + + + 1 till {0} minuter. Bara operatörens egen aktivitet räknas; bakgrundsuppdateringar och inkommande larm gör det inte. + + + Avsluta passet efter så här många timmar + + + 1 till {0} timmar efter inloggningen, oavsett aktivitet. Nästa pass loggar in igen. + + + Använd alltid delat läge för + + + Sessioner i de här apparna i den här organisationen låses alltid vid inaktivitet och avslutas med passet, oavsett installationens inställning. Inloggningar som inte anger sin app räknas också, så integrationer som loggar in med lösenord låses också. Använd appversioner som stöder delat läge. + + + Unit + + + IC (Ledning) + + + Dispatch + + + Välj en låsning vid inaktivitet på 1 till {0} minuter. + + + Välj en passlängd på 1 till {0} timmar. + + + Läget för delade enheter är ännu inte tillgängligt i den här installationen, så det kan inte krävas för en annan app. + + + Identitetsleverantörens MFA + + + Ange för Resgrid hur din identitetsleverantör rapporterar att den har verifierat en medlem med MFA. Där organisationen godtar identitetsleverantörens MFA räknas en inloggning eller verifiering som bär ett av värdena som MFA, och medlemmarna behöver ingen autentiserare från Resgrid för det. Varje ändring måste klara en testinloggning innan den börjar gälla. + + + Konfigurera och aktivera en SSO-konfiguration först. + + + Gäller: version {0}, testad {1}. + + + Gäller inte: version {0} har inte klarat sitt test ännu. + + + Ingen mappning är sparad. + + + Vad Resgrid begär + + + Vad som räknas som MFA + + + Ett värde per rad. Värdena måste stämma exakt, inklusive versaler. Ett svar måste innehålla minst ett av värdena. + + + acr_values att begära (OIDC) + + + claims-begäran (OIDC, JSON) + + + Klassreferenser för RequestedAuthnContext (SAML) + + + amr-värden (OIDC) + + + acr-värden (OIDC) + + + acrs-värden (OIDC, autentiseringskontext) + + + AuthnContextClassRef-värden (SAML) + + + Spara mappning + + + Ta bort mappning + + + Mappningen har sparats. Den börjar gälla när den klarar en testinloggning. + + + Mappningen har tagits bort. Identitetsleverantörens MFA räknas inte längre i organisationen. + + + Mappningen kan inte användas: {0} + + + För att ändra mappningen krävs en nylig verifiering med din autentiseringsapp eller en nyckel. Identitetsleverantörens MFA kan inte godkänna ändringen. + + + Identitetsleverantörens MFA-mappning + + + Testa med en inloggning + + + Testet skickar dig nu till identitetsleverantören och begär MFA. När den returnerar ett värde som mappningen räknar börjar den här versionen gälla. + diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx index 9e9034dd4..5fe204de9 100644 --- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx @@ -60,379 +60,1468 @@ - Security and Permissions + Безпека та дозволи - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Тут можна налаштувати дозволи для вашого підрозділу, наприклад які користувачі або ролі можуть створювати виклики або хто має право додавати й видаляти користувачів. Зміни дозволів набудуть чинності під час наступного входу у вебзастосунок Resgrid. - Audit Logs + Журнали аудиту + + + Фільтрувати за типом аудиту + + + Усі типи аудиту + + + Шукайте за іменем користувача, ID користувача чи аудиту, адресою електронної пошти, датою/часом або типом аудиту. Пошук і сортування застосовуються в межах вибраного типу аудиту. + + + Пошук у журналах аудиту: + + + Ім'я, ID, ел. пошта, дата/час або тип + + + Мітка часу + + + Тип + + + Ким записано + + + Результат + + + Повідомлення + + + Пошукові терміни + + + Дії + + + Успішно + + + Не вдалося + + + Переглянути + + + Показано записи з _START_ по _END_ із _TOTAL_ + + + Показано записи з 0 по 0 із 0 + + + (відфільтровано з _MAX_ записів) + + + Показати _MENU_ записів + + + Завантаження... + + + Немає записів журналу аудиту + + + Відповідних записів журналу аудиту не знайдено + + + Перша + + + Остання + + + Наступна + + + Попередня + + + : активуйте, щоб відсортувати стовпець за зростанням + + + : активуйте, щоб відсортувати стовпець за спаданням + + + Система + + + Невідомо + + + Перегляд запису журналу аудиту + + + Запис журналу аудиту + + + ID запису журналу аудиту: + + + ID підрозділу: + + + Тип аудиту: + + + ID типу запису: + + + Опис типу: + + + Результат: + + + Ким записано: + + + ID користувача: + + + Записано (місцевий час): + + + Записано (UTC): + + + IP-адреса: + + + Ім'я сервера: + + + ID об'єкта: + + + ID підрозділу об'єкта: + + + Агент користувача: + + + Повідомлення: + + + Дані: + + + Не записано + + + Налаштування підрозділу змінено + + + Користувача додано + + + Користувача вилучено + + + Групу додано + + + Групу вилучено + + + Групу змінено + + + Одиницю додано + + + Одиницю вилучено + + + Одиницю змінено + + + Профіль оновлено + + + Дозволи змінено + + + Підписку оновлено + + + Підписку створено + + + Підписку скасовано + + + Платіжні дані підписки оновлено + + + Виклик повторно активовано + + + Обліковий запис користувача видалено + + + Підписку на доповнення змінено + + + Видалення підрозділу запитано + + + Запит на видалення підрозділу скасовано + + + Статичну зміну видалено + + + Статичну зміну оновлено + + + Власний статус додано + + + Власний статус вилучено + + + Власний статус оновлено + + + Деталі власного статусу оновлено + + + Тип виклику додано + + + Тип виклику відредаговано + + + Тип виклику вилучено + + + Пріоритет виклику додано + + + Пріоритет виклику відредаговано + + + Пріоритет виклику вилучено + + + Тип одиниці додано + + + Тип одиниці відредаговано + + + Тип одиниці вилучено + + + Тип сертифіката додано + + + Тип сертифіката відредаговано + + + Тип сертифіката вилучено + + + Категорію документів додано + + + Категорію документів відредаговано + + + Категорію документів вилучено + + + Документ додано + + + Документ відредаговано + + + Документ вилучено + + + Категорію нотаток додано + + + Категорію нотаток відредаговано + + + Категорію нотаток вилучено + + + Нотатку додано + + + Нотатку відредаговано + + + Нотатку вилучено + + + Контакт додано + + + Контакт відредаговано + + + Контакт вилучено + + + Категорію контактів додано + + + Категорію контактів відредаговано + + + Категорію контактів вилучено + + + Тип нотатки контакту додано + + + Тип нотатки контакту відредаговано + + + Тип нотатки контакту вилучено + + + Робочий процес створено + + + Робочий процес оновлено + + + Робочий процес видалено + + + Крок робочого процесу додано + + + Крок робочого процесу оновлено + + + Крок робочого процесу видалено + + + Облікові дані робочого процесу додано + + + Облікові дані робочого процесу оновлено + + + Облікові дані робочого процесу видалено + + + Код підтвердження контакту надіслано + + + Контакт підтверджено + + + Підтвердження контакту не вдалося + + + Двофакторну автентифікацію ввімкнено + + + Двофакторну автентифікацію вимкнено + + + Двофакторний вхід підтверджено + + + Двофакторний код відновлення використано + + + Додаткову двофакторну перевірку підтверджено + + + Конфігурацію SSO створено + + + Конфігурацію SSO оновлено + + + Конфігурацію SSO видалено + + + Вхід через SSO виконано + + + Вхід через SSO не вдався + + + Користувача SSO підготовлено + + + Користувача SCIM створено + + + Користувача SCIM оновлено + + + Користувача SCIM деактивовано + + + Користувача SCIM видалено + + + Автентифікація SCIM не вдалася + + + Користувача SCIM повторно активовано + + + Групи SCIM перелічено + + + Користувачів SCIM перелічено + + + Користувача SCIM отримано + + + Токен Bearer SCIM підготовлено + + + Токен Bearer SCIM замінено + + + Визначення UDF створено + + + Визначення UDF оновлено + + + Визначення UDF видалено + + + Поле UDF додано + + + Поле UDF оновлено + + + Поле UDF вилучено + + + Значення полів UDF збережено + + + Маршрут створено + + + Маршрут оновлено + + + Маршрут видалено + + + Маршрут розпочато + + + Маршрут завершено + + + Маршрут скасовано + + + Маршрут призупинено + + + Маршрут відновлено + + + Прибуття на зупинку маршруту зареєстровано + + + Відбуття із зупинки маршруту зареєстровано + + + Зупинку маршруту пропущено + + + Відхилення від маршруту виявлено + + + Відхилення від маршруту прийнято до відома + + + Конфігурацію таймера реєстрації створено + + + Конфігурацію таймера реєстрації оновлено + + + Конфігурацію таймера реєстрації видалено + + + Перевизначення таймера реєстрації створено + + + Перевизначення таймера реєстрації оновлено + + + Перевизначення таймера реєстрації видалено + + + Реєстрацію виконано + + + Таймер реєстрації для виклику ввімкнено + + + Таймер реєстрації для виклику вимкнено + + + Реєстрація входу на подію календаря + + + Реєстрація виходу з події календаря + + + Час реєстрації в календарі оновлено + + + Запис реєстрації в календарі видалено + + + Реєстрація в календарі адміністратором + + + Запис журналу створено + + + Запис журналу видалено + + + Тест зв'язку створено + + + Тест зв'язку оновлено + + + Тест зв'язку видалено + + + Запуск тесту зв'язку розпочато + + + Джерело попереджень про погоду створено + + + Джерело попереджень про погоду оновлено + + + Джерело попереджень про погоду видалено + + + Джерело попереджень про погоду ввімкнено + + + Джерело попереджень про погоду вимкнено + + + Зону попереджень про погоду створено + + + Зону попереджень про погоду оновлено + + + Зону попереджень про погоду видалено + + + Зону попереджень про погоду ввімкнено + + + Зону попереджень про погоду вимкнено + + + Налаштування попереджень про погоду змінено + + + Прапорець функції змінено + + + Перевизначення прапорця функції змінено + + + Пристрій відстеження одиниці створено + + + Пристрій відстеження одиниці оновлено + + + Пристрій відстеження одиниці вимкнено + + + Пристрій відстеження одиниці видалено + + + Облікові дані відстеження одиниці створено + + + Облікові дані відстеження одиниці замінено + + + Облікові дані відстеження одиниці відкликано + + + Видалення підрозділу здійснено + + + Повідомлення чату видалено модератором + + + Користувачеві чату вимкнено звук + + + Користувачеві чату ввімкнено звук + + + Користувача чату заблоковано + + + Користувача чату розблоковано + + + Канал чату заблоковано + + + Канал чату розблоковано + + + Канал чату архівовано + + + Позначку в чаті розглянуто + + + Налаштування чату змінено + + + Експорт чату запитано + + + Експорт чату завантажено + + + Повідомлення для модерації подано + + + Запит на модерацію повторно відкрито + + + Запит на модерацію завершено + + + Доказ модерації завантажено + + + Пароль скинуто адміністратором + + + Сеанси входу користувача відкликано + + + Винятки з додаткової перевірки захисту даних змінено + + + План реагування контакту додано + + + План реагування контакту оновлено + + + План реагування контакту вилучено + + + Вкладення контакту додано + + + Вкладення контакту вилучено + + + Шаблон контрольного списку додано + + + Шаблон контрольного списку оновлено + + + Шаблон контрольного списку опубліковано + + + Шаблон контрольного списку виведено з використання + + + Шаблон контрольного списку вилучено + + + Виконання контрольного списку розпочато + + + Хід виконання контрольного списку збережено + + + Виконання контрольного списку подано + + + Виконання контрольного списку засвідчено свідком + + + Файл контрольного списку додано + + + Файл контрольного списку вилучено + + + Розклад контрольного списку додано + + + Розклад контрольного списку оновлено + + + Заплановану перевірку за контрольним списком не виконано + + + Заплановану перевірку за контрольним списком пропущено + + + Налаштування нагадувань про контрольні списки оновлено + + + Наряд на роботи змінено + + + Інвентар змінено + + + Профіль оплати змінено + + + Тариф змінено + + + Рахунок створено + + + Рахунок оновлено + + + Рахунок надіслано + + + Рахунок анульовано + + + Платіж за рахунком зареєстровано + + + Реквізити підрозділу для рахунків змінено + + + Платіжне підключення встановлено + + + Платіжне підключення відключено + + + Платіжне підключення відкликано + + + Платіжне підключення потребує дії + + + Запит на оплату рахунку створено + + + Платіж за рахунком повернуто + + + Платіж за рахунком оскаржено + + + Платіжний вебхук відхилено + + + Запит на оплату рахунку не вдався + + + Запит на оплату рахунку прострочено + + + Сертифікат додано + + + Сертифікат оновлено + + + Сертифікат вилучено + + + Стан сертифіката змінено + + + Сертифікат підтверджено + + + Кредит сертифіката додано + + + Кредит сертифіката вилучено + + + Вимогу ролі щодо сертифікатів змінено + + + Налаштування сертифікатів підрозділу змінено + + + Учасника ролі додано + + + Учасника ролі вилучено + + + Учасника ролі вилучено через сертифікат + + + Сертифікат одиниці додано + + + Сертифікат одиниці оновлено + + + Сертифікат одиниці вилучено + + + Стан сертифіката одиниці змінено + + + Розгортання створено + + + Розгортання оновлено + + + Статус розгортання змінено + + + Склад розгортання змінено + + + Обладнання розгортання змінено + + + Вкладення розгортання додано + + + Вкладення розгортання вилучено + + + Звіт про час створено + + + Звіт про час оновлено + + + Звіт про час подано + + + Звіт про час затверджено + + + Звіт про час анульовано + + + Витрату розгортання додано + + + Витрату розгортання оновлено + + + Витрату розгортання вилучено + + + Тарифний план створено + + + Тарифний план оновлено + + + Тарифний план видалено + + + Позицію тарифного плану змінено + + + Тарифну надбавку змінено + + + Договір на обслуговування створено + + + Договір на обслуговування оновлено + + + Статус договору на обслуговування змінено + + + Договір на обслуговування видалено + + + Документ відповідності додано + + + Документ відповідності оновлено + + + Документ відповідності вилучено + + + Пропозицію створено + + + Пропозицію оновлено + + + Пропозицію надіслано + + + Пропозицію прийнято + + + Пропозицію відхилено + + + Пропозицію відкликано + + + Пропозицію прострочено + + + Пропозицію перетворено на розгортання + + + Пропозицію видалено + + + Звіт про час включено в рахунок + + + Рахунок розгортання сформовано + + + Профіль агенції Cal OES MARS змінено + + + Профіль ресурсу Cal OES MARS змінено + + + Профіль ставок Cal OES MARS змінено + + + Чернетку ставок Cal OES MARS складено + + + Ставку Cal OES MARS переглянуто + + + Угоду Cal OES MARS змінено + + + Угоду Cal OES MARS зафіксовано + + + Робочий елемент Cal OES MARS підготовлено + + + Робочий елемент Cal OES MARS перевірено + + + Відшкодування Cal OES MARS обчислено + + + Робочий елемент Cal OES MARS відкрито для передачі + + + Зовнішній статус Cal OES MARS зафіксовано + + + Рахунок Cal OES MARS затверджено + + + Рахунок Cal OES MARS відхилено + + + Платіж Cal OES MARS звірено + + + Робочий елемент Cal OES MARS видалено + + + Профіль роботодавця змінено + + + Установу персоналу змінено + + + Працевлаштування змінено + + + Оплату персоналу змінено + + + Річні дані оплати персоналу імпортовано + + + Демографічні дані звітності про оплату змінено + + + Звіт про оплату створено + + + Звіт про оплату перевірено + + + Звіт про оплату заморожено + + + Звіт про оплату експортовано + + + Звіт про оплату позначено засвідченим + + + Звіт про оплату виправлено + + + Профіль витрат ресурсу змінено + + + Використання ресурсів змінено + + + Розрахунок польових витрат створено + + + Розрахунок польових витрат заморожено + + + Користувача повторно активовано + + + Конфігурацію підрозділу змінено + + + Перегляд Admin Assist змінено + + + Доступ до діагностики Admin Assist + + + Налаштування диспетчеризації з ШІ оновлено + + + Доступ до плану Admin Assist + + + Політику безпеки змінено - Permission + Дозвіл Нотатка - Value + Значення Ролі - Two-Factor Authentication (2FA) Enforcement + Обов'язкова двофакторна автентифікація (2FA) - Require authenticator-app 2FA for admin users. When enabled, admins who have not set up 2FA will be redirected to enroll before accessing administrative features. + Вимагати 2FA через застосунок автентифікації для адміністраторів. Якщо ввімкнено, адміністраторів, які не налаштували 2FA, буде перенаправлено на її налаштування, перш ніж вони отримають доступ до адміністративних функцій. - Require 2FA for Admins + Вимагати 2FA для адміністраторів - Controls which admin-level users must enroll in authenticator-app 2FA (Google Authenticator, Microsoft Authenticator, Authy, etc.). + Визначає, які користувачі з правами адміністратора повинні налаштувати 2FA через застосунок автентифікації (Google Authenticator, Microsoft Authenticator, Authy тощо). - Disabled (no requirement) + Вимкнено (без вимоги) - Department Admins + Managing User + Адміністратори підрозділу + керівний користувач - Department Admins + Managing User + Group Admins + Адміністратори підрозділу + керівний користувач + адміністратори груп - 2FA enforcement setting saved. + Налаштування обов'язкової 2FA збережено. - Failed to save 2FA enforcement setting. + Не вдалося зберегти налаштування обов'язкової 2FA. - Cannot enable 2FA enforcement: the managing user of this department does not have two-factor authentication enabled on their account. The managing user must enable 2FA before this setting can be turned on. + Неможливо ввімкнути обов'язкову 2FA: керівний користувач цього підрозділу не ввімкнув двофакторну автентифікацію у своєму обліковому записі. Керівний користувач має ввімкнути 2FA, перш ніж це налаштування можна буде ввімкнути. - Cannot enable 2FA enforcement: you do not have two-factor authentication enabled on your own account. You must enable 2FA before you can require it for others. + Неможливо ввімкнути обов'язкову 2FA: у вашому власному обліковому записі не ввімкнено двофакторну автентифікацію. Ви повинні ввімкнути 2FA, перш ніж вимагати її від інших. - Cannot enable 2FA enforcement: neither you nor the managing user have two-factor authentication enabled. Both must enable 2FA before this setting can be turned on. + Неможливо ввімкнути обов'язкову 2FA: ні у вас, ні в керівного користувача не ввімкнено двофакторну автентифікацію. Ви обоє маєте ввімкнути 2FA, перш ніж це налаштування можна буде ввімкнути. - This setting is locked until the above conditions are met. + Це налаштування заблоковано, доки не буде виконано наведені вище умови. - Permission + Дозвіл Нотатка - Selection + Вибір - Group Only + Лише група Ролі - No Roles + Без ролей - N/A + Н/Д + + + Усі + + + Адміністратори підрозділу + + + Адміністратори підрозділу та груп + + + Адміністратори підрозділу та вибрані ролі + + + Адміністратори підрозділу та груп, а також вибрані ролі - Security and Permissions + Безпека та дозволи + + + Головна - Here you can set the permissions for your department, for example which users or roles can create calls, or who is authorized to create and remove users. Changes to the permissions will take effect on the next login to the Resgrid web application. + Тут можна налаштувати дозволи для вашого підрозділу, наприклад які користувачі або ролі можуть створювати виклики або хто має право додавати й видаляти користувачів. Зміни дозволів набудуть чинності під час наступного входу у вебзастосунок Resgrid. - Who can Add Users + Хто може додавати користувачів - This option determines who can add users/personnel to the department. By default only Department Administrators (and the managing member) can add users. But Group Admins can also be allowed to add users (limited only to the group they are an admin of). + Визначає, хто може додавати користувачів/персонал до підрозділу. За замовчуванням додавати користувачів можуть лише адміністратори підрозділу (і керівний учасник). Проте це можна дозволити й адміністраторам груп (лише в межах групи, адміністраторами якої вони є). - Who can Remove Users + Хто може видаляти користувачів - This option determines who can remove users/personnel from the department. By default only Department Administrators (and the managing member) can remove users. But Group Admins can also be allowed to remove users (limited only to the users in the group they are an admin of). + Визначає, хто може видаляти користувачів/персонал із підрозділу. За замовчуванням видаляти користувачів можуть лише адміністратори підрозділу (і керівний учасник). Проте це можна дозволити й адміністраторам груп (лише для користувачів групи, адміністраторами якої вони є). - Who can Create Calls + Хто може створювати виклики - This option determines who can manually create calls from the Resgrid system. By default Everyone can create calls. + Визначає, хто може вручну створювати виклики в системі Resgrid. За замовчуванням створювати виклики можуть усі. - Who can Delete Calls + Хто може видаляти виклики - This option determines who can delete calls from the Resgrid system. By default Everyone can delete calls. + Визначає, хто може видаляти виклики із системи Resgrid. За замовчуванням видаляти виклики можуть усі. - Who can Close Calls + Хто може закривати виклики - This option determines who can close calls from the Resgrid system. By default Everyone can close calls. + Визначає, хто може закривати виклики в системі Resgrid. За замовчуванням закривати виклики можуть усі. - Who can Add Data To Calls + Хто може додавати дані до викликів - This option determines who can add data; like images, notes and files, to calls from the Resgrid system. By default Everyone can add data to calls. + Визначає, хто може додавати до викликів у системі Resgrid дані, як-от зображення, нотатки та файли. За замовчуванням додавати дані до викликів можуть усі. - Who can Create Trainings + Хто може створювати навчання - This option determines who can create trainings. By default only Department Admins can create trainings. + Визначає, хто може створювати навчання. За замовчуванням створювати навчання можуть лише адміністратори підрозділу. - Who can Add Documents + Хто може додавати документи - This option determines who can add documents. By default Everyone can add documents. + Визначає, хто може додавати документи. За замовчуванням додавати документи можуть усі. - Who can Create Calendar Entries + Хто може створювати записи календаря - This option determines who can create calendar entries. By default Everyone can create calendar entries. + Визначає, хто може створювати записи календаря. За замовчуванням створювати записи календаря можуть усі. - Who can Create Notes + Хто може створювати нотатки - This option determines who can create notes. By default Everyone can create notes. + Визначає, хто може створювати нотатки. За замовчуванням створювати нотатки можуть усі. - Who can Add Log Entries + Хто може додавати записи журналу - This option determines who can add log entries. By default Everyone can add log entries. + Визначає, хто може додавати записи журналу. За замовчуванням додавати записи журналу можуть усі. - Who can Create Shifts + Хто може створювати зміни - This option determines who can create and edit shifts. By default only Department Admins can create and edit shifts. + Визначає, хто може створювати й редагувати зміни. За замовчуванням створювати й редагувати зміни можуть лише адміністратори підрозділу. - Who can View Personal Info + Хто може переглядати персональні дані - This option determines who can view personal information (PII) about personnel in the system. For example: Email Address, Phone Numbers, etc. By default Everyone can view this information. + Визначає, хто може переглядати персональні дані (PII) персоналу в системі, наприклад адресу електронної пошти, номери телефонів тощо. За замовчуванням цю інформацію можуть переглядати всі. - Who can Adjust Inventory + Хто може коригувати інвентар - This option determines who can adjust inventory levels in the system. By default Everyone can adjust inventory. + Визначає, хто може коригувати залишки інвентарю в системі. За замовчуванням коригувати інвентар можуть усі. - Who can see the Location of Personnel + Хто може бачити місцезнаходження персоналу - This option determines who can see the location of personnel on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Визначає, хто може бачити місцезнаходження персоналу на картах. Щоб обмежити цей параметр лише адміністраторами груп і ролями в межах групи, поставте позначку «Лише група». - Who can see the Location of Units + Хто може бачити місцезнаходження одиниць - This option determines who can see the location of units on the maps. To lock the option to just group admins and roles within a group you need to check the Group Only option. + Визначає, хто може бачити місцезнаходження одиниць на картах. Щоб обмежити цей параметр лише адміністраторами груп і ролями в межах групи, поставте позначку «Лише група». - Who can send messages + Хто може надсилати повідомлення - This option determines who can create and send messages (in-system mail). By default everyone can create and send messages. + Визначає, хто може створювати й надсилати повідомлення (внутрішню пошту системи). За замовчуванням створювати й надсилати повідомлення можуть усі. - Who can view users + Хто може переглядати користувачів - By default all users can see all other users in the system. This option allows you to limit who can see users in the system. + За замовчуванням усі користувачі бачать усіх інших користувачів у системі. Цей параметр дає змогу обмежити, хто може бачити користувачів у системі. - Who can view units + Хто може переглядати одиниці - By default all users can see all units in the system. This option allows you to limit who can view units in the system. + За замовчуванням усі користувачі бачать усі одиниці в системі. Цей параметр дає змогу обмежити, хто може переглядати одиниці в системі. - Who can view Contacts + Хто може переглядати контакти - By default all users can see all contacts in the system. This option allows you to limit who can view contacts in the system. If a user cannot view Contacts they also won't be able to add them to a call. + За замовчуванням усі користувачі бачать усі контакти в системі. Цей параметр дає змогу обмежити, хто може переглядати контакти в системі. Якщо користувач не може переглядати контакти, він також не зможе додавати їх до виклику. - Who can edit or create Contacts + Хто може редагувати або створювати контакти - By default all users can create and edit contacts in the system. This option allows you to limit who can create or edit contacts in the system. + За замовчуванням усі користувачі можуть створювати й редагувати контакти в системі. Цей параметр дає змогу обмежити, хто може створювати або редагувати контакти в системі. - Who can delete Contacts + Хто може видаляти контакти - By default all users can delete contacts in the system. This option allows you to limit who can delete contacts in the system. + За замовчуванням усі користувачі можуть видаляти контакти в системі. Цей параметр дає змогу обмежити, хто може видаляти контакти в системі. - Who can Create/Edit Workflows + Хто може створювати/редагувати робочі процеси - This option determines who can create, edit, and delete workflows and workflow steps. By default only Department Admins can manage workflows. + Визначає, хто може створювати, редагувати й видаляти робочі процеси та їхні кроки. За замовчуванням керувати робочими процесами можуть лише адміністратори підрозділу. - Who can Manage Workflow Credentials + Хто може керувати обліковими даними робочих процесів - This option determines who can create, edit, and delete encrypted credentials used by workflow actions (e.g., SMTP passwords, API keys). By default only Department Admins can manage credentials. + Визначає, хто може створювати, редагувати й видаляти зашифровані облікові дані, які використовують дії робочих процесів (наприклад, паролі SMTP, ключі API). За замовчуванням керувати обліковими даними можуть лише адміністратори підрозділу. - Who can View Workflow Runs + Хто може переглядати запуски робочих процесів - This option determines who can view workflow execution history, run logs, and health dashboards. By default only Department Admins can view workflow runs. + Визначає, хто може переглядати історію виконання робочих процесів, журнали запусків і панелі стану. За замовчуванням переглядати запуски робочих процесів можуть лише адміністратори підрозділу. - Single Sign-On (SSO) & SCIM + Єдиний вхід (SSO) і SCIM - Single Sign-On & Identity Provisioning + Єдиний вхід і підготовка облікових записів SSO / SCIM - Security Policy + Політика безпеки - Add OIDC Config + Додати конфігурацію OIDC - Add SAML 2.0 Config + Додати конфігурацію SAML 2.0 - What is SSO & SCIM? + Що таке SSO і SCIM? - Single Sign-On (SSO) + Єдиний вхід (SSO) - Allow department members to log in with their existing corporate identity (Microsoft Entra ID, Okta, Google Workspace, etc.) instead of a separate Resgrid password. + Дозвольте учасникам підрозділу входити за допомогою наявного корпоративного облікового запису (Microsoft Entra ID, Okta, Google Workspace тощо) замість окремого пароля Resgrid. - OIDC — Modern protocol, ideal for Entra, Okta, Auth0, Google + OIDC — сучасний протокол, ідеальний для Entra, Okta, Auth0, Google - SAML 2.0 — Widely supported by government & enterprise IdPs + SAML 2.0 — широко підтримується державними та корпоративними IdP - SCIM 2.0 Provisioning + Підготовка SCIM 2.0 - Automatically sync users from your identity provider. When you onboard or offboard staff in your corporate directory, Resgrid reflects those changes automatically — no manual invite/remove steps. + Автоматично синхронізуйте користувачів із вашого постачальника ідентичності. Коли ви приймаєте або звільняєте співробітників у корпоративному каталозі, Resgrid автоматично відображає ці зміни — без ручного запрошення чи видалення. - Auto-create new members when added in your IdP + Автоматично створювати нових учасників, коли їх додано у вашому IdP - Disable/remove members when deprovisioned + Вимикати/видаляти учасників, коли їхню підготовку скасовано - Keep names & email addresses in sync + Синхронізувати імена та адреси електронної пошти - Security Policy + Політика безпеки - Enforce department-wide compliance controls alongside SSO: + Застосовуйте засоби контролю відповідності для всього підрозділу разом із SSO: - Mandate MFA for all members + Обов'язкова MFA для всіх учасників - Restrict login to SSO only (disable passwords) + Вхід лише через SSO (вимкнення паролів) - Limit logins to specific IP CIDR ranges + Обмеження входу певними діапазонами IP (CIDR) - Set password expiration & complexity rules + Правила строку дії та складності паролів - Classify data level (Unclassified / CUI / Confidential) + Класифікація рівня даних (некласифіковані / CUI / конфіденційні) - Mobile App Discovery URL + URL виявлення для мобільного застосунку - The Resgrid mobile app uses this URL to discover your SSO settings before showing the login screen. Share it with your mobile team if needed. + Мобільний застосунок Resgrid використовує цю URL-адресу, щоб виявити ваші налаштування SSO перед показом екрана входу. За потреби передайте її команді, що відповідає за мобільні пристрої. - Copy + Копіювати - Copied to clipboard! + Скопійовано в буфер обміну! - SSO Configurations + Конфігурації SSO - No SSO configurations yet. Use the buttons above to add OIDC or SAML 2.0. + Конфігурацій SSO ще немає. Скористайтеся кнопками вище, щоб додати OIDC або SAML 2.0. - Provider + Постачальник - Identifier / Endpoint + Ідентифікатор / кінцева точка Статус - Local Login + Локальний вхід - Auto-Provision + Автопідготовка SCIM - Created + Створено Дії - Enabled + Увімкнено - Disabled + Вимкнено Так - SSO Only + Лише SSO - On + Увімк. - Off + Вимк. Активний - No Token + Немає токена - Off + Вимк. Редагувати @@ -441,10 +1530,37 @@ SCIM - Confirm Delete + Підтвердження видалення - Are you sure you want to delete the {0} SSO configuration? This cannot be undone. + Ви впевнені, що хочете видалити конфігурацію SSO {0}? Цю дію неможливо скасувати. + + + Недійсний тип постачальника. + + + Конфігурація SSO для {0} уже існує. Щоб змінити її, скористайтеся кнопкою «Редагувати». + + + Потрібно вказати ID клієнта OIDC. + + + Центр автентифікації OIDC має бути дійсною URL-адресою HTTPS. + + + Для перевірки тверджень SAML потрібен сертифікат підпису IdP. + + + URL єдиного входу постачальника ідентичності має бути дійсною URL-адресою HTTPS. + + + Конфігурацію SSO {0} успішно створено. + + + Конфігурацію SSO успішно оновлено. + + + Конфігурацію SSO {0} видалено. Скасувати @@ -453,10 +1569,10 @@ Видалити - New SSO Configuration + Нова конфігурація SSO - Edit SSO Configuration + Редагування конфігурації SSO Новий @@ -465,226 +1581,256 @@ Редагувати - Step 1 + Крок 1 - Provider & Basic Settings + Постачальник і основні налаштування - Identity Provider Protocol + Протокол постачальника ідентичності + + + OIDC (OpenID Connect) — Microsoft Entra, Okta, Google, Auth0 + + + SAML 2.0 — більшість корпоративних і державних IdP - Enable this SSO configuration + Увімкнути цю конфігурацію SSO - Only one configuration per provider type is active at a time. + Одночасно може бути активна лише одна конфігурація кожного типу постачальника. - Allow local password login + Дозволити локальний вхід за паролем - When checked, users can still log in with username & password in addition to SSO. Disable this together with the Security Policy's Require SSO flag to enforce SSO-only login. + Якщо позначено, користувачі й надалі можуть входити за ім'ям користувача та паролем на додаток до SSO. Щоб дозволити вхід лише через SSO, вимкніть цей параметр разом із прапорцем «Вимагати SSO» в політиці безпеки. - Auto-provision new users + Автоматична підготовка нових користувачів - Automatically create a Resgrid account when a user authenticates via SSO for the first time and no matching email is found. Leave off to require manual invitation first. + Автоматично створювати обліковий запис Resgrid, коли користувач уперше автентифікується через SSO, а відповідної адреси електронної пошти не знайдено. Залиште вимкненим, щоб спершу вимагати запрошення вручну. - Enable SCIM 2.0 provisioning + Увімкнути підготовку SCIM 2.0 - Allows your IdP to automatically create/update/deactivate members. After saving, go to the SCIM Setup page to generate a bearer token. + Дозволяє вашому IdP автоматично створювати, оновлювати й деактивувати учасників. Після збереження перейдіть на сторінку налаштування SCIM, щоб згенерувати токен Bearer. - Default Rank for Auto-Provisioned Users + Звання за замовчуванням для автоматично підготовлених користувачів + + + (Без звання за замовчуванням) - Optional. Applied only when Auto-Provision creates a new member. + Необов'язково. Застосовується лише тоді, коли автоматична підготовка створює нового учасника. - Step 2 — OIDC + Крок 2 — OIDC - OpenID Connect Settings + Налаштування OpenID Connect - Where to find these values: In your IdP, register Resgrid as a Public Client (PKCE, no client secret required for mobile) or Web App (with secret for server-side flows). Copy the Client ID and Issuer URL from the registered application. + Де знайти ці значення: зареєструйте Resgrid у своєму IdP як публічний клієнт (PKCE, для мобільних застосунків секрет клієнта не потрібен) або як вебзастосунок (із секретом для серверних потоків). Скопіюйте ID клієнта та URL видавця (Issuer) із зареєстрованого застосунку. - Authority / Issuer URL + URL центру автентифікації / видавця (Authority / Issuer) https://login.microsoftonline.com/{tenant-id}/v2.0 - Examples: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com + Приклади: Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 | Okta: https://{your-domain}.okta.com/oauth2/default | Google: https://accounts.google.com - Client ID + ID клієнта xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - The public client identifier assigned by your IdP. Safe to display — not a secret. + Публічний ідентифікатор клієнта, призначений вашим IdP. Його безпечно показувати — це не секрет. - Client Secret + Секрет клієнта - Secret stored + Секрет збережено - Encrypted at rest using your department-specific key. Leave blank if using PKCE (mobile/SPA — no secret needed). + Зберігається в зашифрованому вигляді за допомогою окремого ключа вашого підрозділу. Залиште порожнім, якщо використовуєте PKCE (мобільні застосунки/SPA — секрет не потрібен). - Leave blank to keep the existing secret unchanged. + Залиште порожнім, щоб не змінювати наявний секрет. - Step 2 — SAML 2.0 + Крок 2 — SAML 2.0 - SAML 2.0 Settings + Налаштування SAML 2.0 - Where to find these values: In your IdP, create a new SAML application. Enter the SP Entity ID and ACS URL shown below into your IdP, then paste the IdP metadata URL or certificate back here. + Де знайти ці значення: створіть у своєму IdP новий застосунок SAML. Введіть у свій IdP наведені нижче SP Entity ID та URL ACS, а потім вставте сюди URL метаданих IdP або сертифікат. - SP Entity ID (enter this into your IdP) + SP Entity ID (введіть його у свій IdP) - ACS URL (Assertion Consumer Service — enter this into your IdP) + URL ACS (служба обробки тверджень — введіть його у свій IdP) - This URL uses an encrypted department token — never exposes your plain department code. + Ця URL-адреса використовує зашифрований токен підрозділу — ваш код підрозділу ніколи не розкривається у відкритому вигляді. - SP Entity ID (editable) + SP Entity ID (можна редагувати) https://app.resgrid.com/saml/DEPT - Must match exactly what you entered in the IdP. + Має точно збігатися з тим, що ви ввели в IdP. - IdP Metadata URL + URL метаданих IdP https://idp.example.com/metadata.xml - Resgrid will periodically fetch this URL to stay up to date with IdP certificates. + Resgrid періодично завантажуватиме цю URL-адресу, щоб підтримувати сертифікати IdP в актуальному стані. - ACS URL (stored) + URL ACS (збережений) - Auto-filled from the generated URL above. Modify only if you configured a different ACS URL in your IdP. + Автоматично заповнюється згенерованою вище URL-адресою. Змінюйте лише тоді, коли ви налаштували у своєму IdP іншу URL-адресу ACS. + + + URI перенаправлення для входу, розпочатого з Resgrid (введіть його у постачальника ідентичності) + + + Додайте його поряд з уже зареєстрованими URI перенаправлення; старіші версії застосунків Resgrid і далі використовують свої. + + + URI перенаправлення для входу в застосунках Resgrid (введіть кожен у постачальника ідентичності) + + + Кожен застосунок і вебверсія кожного застосунку повертаються на власну адресу, тож зареєструйте їх усі. Їх використовують старіші версії застосунків і застосунки, вхід у яких не розпочинається з Resgrid. + + + {0} (веб) + + + URL єдиного входу постачальника ідентичності + + + Адреса входу SAML постачальника ідентичності (прив'язка HTTP-Redirect). Потрібна для входу, розпочатого із застосунків і вебсайту Resgrid. - IdP Signing Certificate (PEM) + Сертифікат підпису IdP (PEM) - Certificate stored + Сертифікат збережено - Encrypted at rest. Leave blank to keep the existing certificate. + Зберігається в зашифрованому вигляді. Залиште порожнім, щоб зберегти наявний сертифікат. - SP Signing Key (PEM, optional) + Ключ підпису SP (PEM, необов'язково) - Key stored + Ключ збережено - Encrypted at rest. Leave blank if your IdP does not require signed requests. + Зберігається в зашифрованому вигляді. Залиште порожнім, якщо ваш IdP не вимагає підписаних запитів. - Step 3 + Крок 3 - Attribute Mapping + Зіставлення атрибутів - Map claim names your IdP sends to the Resgrid user fields. Leave blank to use the standard claim URIs automatically. The value is the claim name as emitted by your IdP. + Зіставте назви клеймів, які надсилає ваш IdP, з полями користувача Resgrid. Залиште порожнім, щоб автоматично використовувати стандартні URI клеймів. Значення — це назва клейму в тому вигляді, у якому її видає ваш IdP. - Attribute Mapping JSON + JSON зіставлення атрибутів - Must be valid JSON. Use the quick-fill buttons on the right. + Має бути дійсним JSON. Скористайтеся кнопками швидкого заповнення праворуч. - Quick-fill presets + Шаблони швидкого заповнення - Click to auto-fill the mapping JSON with known defaults for your IdP. + Натисніть, щоб автоматично заповнити JSON зіставлення відомими типовими значеннями для вашого IdP. - Supported Resgrid field keys + Підтримувані ключі полів Resgrid - Key + Ключ Опис - User's email address + Адреса електронної пошти користувача - Given / first name + Ім'я - Surname / family name + Прізвище - Unique IdP subject (for account linking) + Унікальний суб'єкт (subject) IdP (для зв'язування облікових записів) - Create Configuration + Створити конфігурацію - Save Changes + Зберегти зміни Скасувати - SCIM 2.0 Setup + Налаштування SCIM 2.0 - SCIM 2.0 Provisioning Setup + Налаштування підготовки SCIM 2.0 - SCIM Setup + Налаштування SCIM - Back to SSO + Назад до SSO - Important — Copy your SCIM Bearer Token Now + Важливо — скопіюйте токен Bearer SCIM зараз - This token is displayed only once and cannot be retrieved again. Copy it immediately and paste it into your identity provider's SCIM configuration. If you lose it, click Rotate Token to generate a new one. + Цей токен відображається лише один раз, і його неможливо отримати знову. Негайно скопіюйте його та вставте в конфігурацію SCIM вашого постачальника ідентичності. Якщо ви його втратите, натисніть «Ротація токена SCIM», щоб згенерувати новий. - Copy Token + Копіювати токен - This token is stored encrypted in the Resgrid database using your department-specific encryption key. + Цей токен зберігається в базі даних Resgrid у зашифрованому вигляді за допомогою окремого ключа шифрування вашого підрозділу. - SCIM Status + Стан SCIM - Provider + Постачальник - SCIM Enabled + SCIM увімкнено Так @@ -693,61 +1839,61 @@ Ні - Bearer Token + Токен Bearer - Configured + Налаштовано - Not Set + Не задано - Rotate SCIM Token + Ротація токена SCIM - Generate SCIM Token + Згенерувати токен SCIM - Rotating generates a new token and invalidates the old one. + Ротація генерує новий токен і анулює старий. - Are you sure? Any existing SCIM integration will break until you update the token in your IdP. + Ви впевнені? Будь-яка наявна інтеграція SCIM перестане працювати, доки ви не оновите токен у своєму IdP. - SCIM Connector Settings + Налаштування конектора SCIM - Enter these values into your identity provider's SCIM provisioning configuration. + Введіть ці значення в конфігурацію підготовки SCIM вашого постачальника ідентичності. - Setting + Налаштування - Value + Значення - SCIM Connector Base URL + Базова URL-адреса конектора SCIM - Authentication Method + Метод автентифікації - Authorization Header + Заголовок авторизації - Custom Header Name + Назва власного заголовка - Custom Header Value + Значення власного заголовка - Supported Resources + Підтримувані ресурси - Supported Update Method + Підтримуваний метод оновлення - Step-by-Step Setup Guide + Покроковий посібник з налаштування Okta @@ -759,22 +1905,22 @@ Google Workspace - Other / Generic + Інше / загальне - Any SCIM 2.0 compatible client can be configured with the following: + Будь-який клієнт, сумісний із SCIM 2.0, можна налаштувати так: - Resgrid SCIM field mapping + Зіставлення полів SCIM у Resgrid - Parameter + Параметр - SCIM field + Поле SCIM - Resgrid effect + Дія в Resgrid @@ -804,7 +1950,7 @@ Згенеруйте або поверніть Bearer Token, який ваш постачальник ідентифікаційних даних використовує для автентифікації запитів SCIM. Токен відображається лише один раз — скопіюйте його до закриття цього діалогу. Підготувати токен Ротація токена - SCIM Bearer Token + Токен Bearer SCIM Цей токен відображається лише один раз і не може бути відновлений. Скопіюйте його зараз і вставте в конфігурацію SCIM вашого постачальника ідентифікаційних даних. Ротація токена негайно анулює поточний токен. Будь-яка активна синхронізація SCIM буде невдалою, поки ви не оновите токен у своєму IdP. Продовжити? Це створить новий токен SCIM для цієї конфігурації. Скопіюйте та збережіть його надійно — він буде показаний лише один раз. Продовжити? @@ -814,112 +1960,136 @@ Для покрокових інструкцій, специфічних для вашого IdP (Okta, Microsoft Entra ID, Google Workspace), та керування токенами відвідайте сторінку налаштування SCIM для кожної конфігурації. - Department Security Policy + Політика безпеки підрозділу - Department Security Policy + Політика безпеки підрозділу - Security Policy + Політика безпеки - Back to SSO + Назад до SSO - Security policy saved successfully. + Політику безпеки успішно збережено. - No active SSO configuration. You can configure password policies below, but Require SSO cannot be enabled until you have an active SSO configuration. + Немає активної конфігурації SSO. Ви можете налаштувати політики паролів нижче, але «Вимагати SSO» не можна ввімкнути, доки у вас не буде активної конфігурації SSO. - Configure SSO → + Налаштувати SSO → - Authentication Controls + Параметри автентифікації - Require MFA for all members + Вимагати MFA для всіх учасників - Members who have not enrolled in MFA will be prompted to do so on next login. + Учасникам, які не налаштували MFA, буде запропоновано зробити це під час наступного входу. - Require SSO — disable password login + Вимагати SSO — вимкнути вхід за паролем - Requires active SSO config + Потрібна активна конфігурація SSO - Warning: Enabling this blocks all username/password logins. Ensure at least one admin has tested SSO login successfully before enabling. + Попередження: увімкнення цього параметра блокує всі входи за ім'ям користувача та паролем. Перш ніж вмикати, переконайтеся, що принаймні один адміністратор успішно перевірив вхід через SSO. - Session Timeout (minutes) + Тайм-аут сеансу (хвилин) - 0 = use system default. 480 = 8 hours. + 0 = використовувати системне значення за замовчуванням. 480 = 8 годин. - Max Concurrent Sessions per User + Максимум одночасних сеансів на користувача - 0 = unlimited. Government environments typically set 1. + 0 = без обмежень. У державних установах зазвичай встановлюють 1. - Allowed IP Ranges (CIDR) + Дозволені діапазони IP (CIDR) - One CIDR block per line, or comma-separated. Empty = allow all. Logins from outside these ranges will be denied. + Один блок CIDR на рядок або через кому. Порожнє поле = дозволити всі. Входи з-поза цих діапазонів буде відхилено. - Data Classification Level + Рівень класифікації даних + + + Некласифіковані + + + CUI - Контрольована некласифікована інформація + + + Конфіденційні - Used for compliance reporting and audit logs. + Використовується у звітності про відповідність вимогам і в журналах аудиту. - Password Policy + Політика паролів - Password policies apply to local (non-SSO) logins only. If you enable Require SSO above, these settings have no effect. + Політики паролів застосовуються лише до локальних входів (не через SSO). Якщо ви ввімкнете «Вимагати SSO» вище, ці налаштування не діятимуть. - Password Expiration (days) + Строк дії пароля (днів) - 0 = passwords never expire. 90 is typical for CUI environments. + 0 = строк дії паролів не обмежено. Для середовищ CUI типове значення — 90. - Minimum Password Length + Мінімальна довжина пароля - 0 = system default (8). NIST recommends 12+; CUI requires 14+. + Мінімум 8 (системне значення за замовчуванням). Політика підрозділу може лише збільшити це значення. NIST рекомендує 12+; CUI вимагає 14+. + + + Складність пароля, яку вимагає система + + + Усі облікові записи Resgrid повинні відповідати наведеному нижче стандарту складності пароля. Його не можна вимкнути політикою підрозділу. + + + Щонайменше 8 символів (або мінімальна довжина підрозділу, указана вище) + + + Щонайменше одна цифра + + + Щонайменше одна велика та одна мала літера - Require password complexity + Вимагати складний пароль - Enforces at least one uppercase letter, one digit, and one special character. + Вимагає щонайменше однієї великої літери, однієї цифри та одного спеціального символу. - Quick presets + Швидкі шаблони - Government / CUI + Державні установи / CUI - Standard Enterprise + Стандартний корпоративний - Minimal + Мінімальний - Save Security Policy + Зберегти політику безпеки Скасувати - Cannot enable SSO-only login: no active SSO configuration exists. Create and enable an SSO configuration first. + Неможливо ввімкнути вхід лише через SSO: немає активної конфігурації SSO. Спершу створіть і ввімкніть конфігурацію SSO. Пароль не може бути порожнім. Пароль повинен містити щонайменше одну цифру. @@ -927,32 +2097,34 @@ Пароль повинен містити щонайменше одну малу літеру. Пароль повинен містити щонайменше {0} символів. Мінімальна довжина пароля не може бути меншою за системний мінімум — 8 символів. - Use Calendar Sync - Controls who can activate and use calendar subscription URLs to sync Resgrid calendar events to external calendar applications. - Dispatch App Login - Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers. - Command App Login - Controls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles. - Advanced Data Protection - These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown. - Manage Data Protection Settings - Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member. - View Protected Call Data - Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch. - Edit Protected Call Data - Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow. - View Protected Personnel Data - Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins. - View Protected Contact Data - Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins. - View Protected Operational Data - Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins. - Export Protected Data - Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered. - Configure Protected Data Delivery - Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered. - Emergency Break-Glass Access - Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered. + Видалення записів журналу + Хто у вашому підрозділі може видаляти записи журналу + Використання синхронізації календаря + Визначає, хто може активувати й використовувати URL-адреси підписки на календар, щоб синхронізувати події календаря Resgrid із зовнішніми календарними застосунками. + Вхід у застосунок Dispatch + Визначає, хто може входити в застосунок Dispatch. Dispatch показує закриті переговори командування, одиниць і учасників реагування щодо кожного інциденту, тому обмежте цей доступ, якщо не всі ваші учасники є диспетчерами. + Вхід у застосунок командування + Визначає, хто може діяти як командир: входити в застосунок IC, встановлювати командування інцидентом на виклику та переглядати командні дошки. Якщо звузити цей дозвіл порівняно з «Усі», вибрані вами люди також зможуть працювати з будь-якою командною дошкою (призначати й переміщувати ресурси, запускати таймери та контроль особового складу), не обіймаючи на ній посади ICS, — це зручно, щоб диспетчери могли допомагати в застосунку Dispatch. Поки вибрано «Усі», дії на дошці доступні лише керівнику інциденту та призначеним ролям ICS. + Розширений захист даних + Ці дозволи визначають, хто може працювати із зашифрованими (захищеними) даними, коли активне доповнення «Розширений захист даних». Кожен показ або редагування додатково потребує недавнього двофакторного підтвердження; ці налаштування визначають, хто взагалі може спробувати це зробити. На відміну від більшості дозволів Resgrid, для незаданих значень діє показаний обмежувальний вибір. + Керування налаштуваннями захисту даних + Хто може змінювати налаштування розширеного захисту даних, як-от вікно підтвердження та параметри вмісту сповіщень. Придбання, реєстрація та скасування завжди залишаються доступними лише керівному учаснику підрозділу. + Перегляд захищених даних викликів + Хто може показувати захищені поля виклику (характер, адреса, контактні дані, нотатки) після двофакторного підтвердження. За замовчуванням — «Усі», оскільки персонал, що реагує на виклик, повинен мати змогу прочитати диспетчерське повідомлення. + Редагування захищених даних викликів + Хто може редагувати захищені поля виклику після двофакторного підтвердження. За замовчуванням — «Усі», відповідно до звичайного процесу роботи з викликами. + Перегляд захищених даних персоналу + Хто може показувати захищену інформацію про персонал (табельні номери, екстрені контакти) після двофакторного підтвердження. За замовчуванням — адміністратори підрозділу. + Перегляд захищених даних контактів + Хто може показувати захищену контактну інформацію (імена, номери телефонів, державні ідентифікатори, місцезнаходження) після двофакторного підтвердження. За замовчуванням — адміністратори підрозділу. + Перегляд захищених оперативних даних + Хто може показувати захищений оперативний вміст (журнали, подані форми, нотатки та вкладення командування інцидентом) після двофакторного підтвердження. За замовчуванням — адміністратори підрозділу та груп. + Експорт захищених даних + Хто може експортувати дані, що містять захищені поля. Експортовані дані виходять з-під захисту Resgrid, тому кожен експорт окремо реєструється в журналі аудиту. За замовчуванням — адміністратори підрозділу; варіант «Усі» навмисно не пропонується. + Налаштування доставки захищених даних + Хто може змінювати, як захищений вміст залишає Resgrid через push-сповіщення, SMS, електронну пошту та голосові дзвінки. За замовчуванням — адміністратори підрозділу; варіант «Усі» навмисно не пропонується. + Аварійний доступ (break-glass) + Хто може використовувати аудитований шлях аварійного доступу до захищених даних. Він працює, лише якщо аварійний доступ увімкнено в політиці захисту підрозділу, потребує зазначення причини, яка записується, і сповіщає підрозділ. За замовчуванням — адміністратори підрозділу; варіант «Усі» навмисно не пропонується. Звіти Ці дозволи керують модулем Звіти, наступником Журналів. Як і для більшості дозволів Resgrid, для неналаштованого рядка діє показане значення за замовчуванням, яке відповідає поточній поведінці Журналів. Під час активації Звітів для вашого підрозділу налаштування «Створити запис журналу» та «Видалити запис журналу» також копіюються у відповідні рядки, якщо ви ще не задали їх тут. Звіти ще не активовано для цього підрозділу. Ви можете підготувати ці налаштування зараз; вони набудуть чинності після активації Звітів. @@ -1012,4 +2184,190 @@ Визначає, хто може переміщувати інвентар між місцями зберігання. За замовчуванням це дозволено адміністраторам департаменту. + + Методи другого фактора + + + Виберіть, які методи підтвердження вважаються багатофакторною автентифікацією в цьому підрозділі. Коди застосунку автентифікації (TOTP) приймаються завжди, тому вимкнення методу нікого не блокує; учасників, які ним користувалися, попросять підтвердити ще раз. + + + Лише керівний учасник підрозділу може змінювати ці налаштування. + + + Ключі доступу ще недоступні в цій системі. Ці налаштування набудуть чинності, коли стануть доступними. + + + Ще недоступно + + + Приймати ключі доступу для входу та перевірок безпеки + + + Ключ доступу, зареєстрований у тому самому застосунку, вважається MFA для входу, зміни підрозділу та чутливих дій. + + + Приймати ключі доступу для захищених даних + + + Ключ доступу вважається MFA для показу й редагування захищених даних. Зміна завершує поточний доступ до захищених даних, тож учасники підтверджують ще раз. + + + Приймати підтвердження із застосунку Responder + + + Там, де приймаються ключі доступу, учасник може підтвердити вхід або запит на захищені дані ключем доступу у своєму застосунку Responder. Ніколи не використовується для змін безпеки. Зміна завершує поточний доступ до захищених даних. + + + Приймати MFA вашого постачальника ідентичності для входу та перевірок безпеки + + + Потрібне перевірене зіставлення MFA у вашій конфігурації SSO. + + + Приймати MFA вашого постачальника ідентичності для захищених даних + + + Потрібне перевірене зіставлення MFA у вашій конфігурації SSO. Зміна завершує поточний доступ до захищених даних. + + + У вашій конфігурації SSO немає перевіреного зіставлення MFA. Збережіть зіставлення та завершіть його перевірку, перш ніж приймати MFA постачальника ідентичності. + + + Щоб приймати MFA постачальника ідентичності, спершу підтвердьте особу застосунком автентифікації або ключем доступу. MFA постачальника ідентичності не може схвалити цю зміну. + + + Дозволити недавньому підтвердженню під час входу відкривати захищені дані + + + Учасникам, які пройшли MFA під час входу, не потрібно підтверджувати ще раз, щоб показати захищені дані в межах вікна підтвердження. Зміна завершує поточний доступ до захищених даних. + + + Дозволити розблокуванню спільного пристрою відкривати захищені дані + + + На спільних планшетах у транспорті та робочих станціях недавнє підтвердження розблокування оператора дає змогу показувати захищені дані. Зміна завершує поточний доступ до захищених даних. + + + Спільні пристрої в транспорті та на робочих станціях + + + Спільні планшети в транспорті та робочі станції диспетчерів блокуються, коли ними ніхто не користується, і завершуються разом зі зміною. Оператори розблоковують їх власним застосунком автентифікації, ключем доступу або підтвердженням у Responder, ніколи паролем, збереженим на пристрої. Суворіше значення тут діє й для вже активних сеансів. + + + Лише керівний учасник підрозділу може змінювати політику спільних пристроїв. + + + Режим спільного пристрою в цьому розгортанні ще недоступний. Ці значення зберігаються, і його не можна вимагати для іншого застосунку, доки він не стане доступним. + + + Блокувати після стількох хвилин бездіяльності + + + Від 1 до {0} хвилин. Враховується лише власна активність оператора; фонові оновлення та вхідні сповіщення — ні. + + + Завершувати зміну після стількох годин + + + Від 1 до {0} годин після входу, незалежно від активності. Наступна зміна входить знову. + + + Завжди використовувати спільний режим для + + + Сеанси цих застосунків у цьому підрозділі завжди блокуються через бездіяльність і завершуються разом зі зміною, незалежно від налаштування інсталяції. Враховуються й входи, які не вказують застосунок, тож інтеграції, що входять за паролем, також блокуються. Використовуйте версії застосунків із підтримкою спільного режиму. + + + Unit + + + IC (Командування) + + + Dispatch + + + Виберіть блокування через бездіяльність від 1 до {0} хвилин. + + + Виберіть тривалість зміни від 1 до {0} годин. + + + Режим спільного пристрою в цьому розгортанні ще недоступний, тому його не можна вимагати для іншого застосунку. + + + MFA постачальника ідентичності + + + Укажіть Resgrid, як ваш постачальник ідентичності повідомляє, що підтвердив учасника за допомогою MFA. Там, де цей підрозділ приймає MFA постачальника ідентичності, вхід або підтвердження з одним із цих значень вважається MFA, і учасникам не потрібен автентифікатор Resgrid. Кожна зміна має пройти тестовий вхід, перш ніж набуде чинності. + + + Спершу налаштуйте й увімкніть конфігурацію SSO. + + + Чинне: версія {0}, перевірено {1}. + + + Не чинне: версія {0} ще не пройшла перевірку. + + + Зіставлення не збережено. + + + Що запитує Resgrid + + + Що вважається MFA + + + Одне значення в рядку. Значення мають збігатися точно, з урахуванням регістру. Відповідь має містити хоча б одне з них. + + + acr_values для запиту (OIDC) + + + Запит claims (OIDC, JSON) + + + Посилання на класи RequestedAuthnContext (SAML) + + + Значення amr (OIDC) + + + Значення acr (OIDC) + + + Значення acrs (OIDC, контекст автентифікації) + + + Значення AuthnContextClassRef (SAML) + + + Зберегти зіставлення + + + Видалити зіставлення + + + Зіставлення збережено. Воно набуде чинності після успішного тестового входу. + + + Зіставлення видалено. MFA постачальника ідентичності більше не враховується в цьому підрозділі. + + + Зіставлення не можна використати: {0} + + + Для зміни зіставлення потрібне недавнє підтвердження застосунком автентифікації або ключем доступу. MFA постачальника ідентичності не може схвалити цю зміну. + + + Зіставлення MFA постачальника ідентичності + + + Перевірити входом + + + Перевірка зараз перенаправить вас до постачальника ідентичності із запитом MFA. Коли він поверне значення, яке враховує зіставлення, ця версія набуде чинності. + diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx index 8e0728c2a..5842966c7 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Statische Schicht löschen diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx index bf75dc559..6147c0ce0 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Supprimer le quart statique diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx index 42b78150c..8a7ecfce9 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Elimina turno statico diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx index fe8859a31..2bbf1612e 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Usuń statyczną zmianę diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx index 95a766f01..456fa2afa 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Ta bort statiskt skift diff --git a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx index f43451251..30fd418b0 100644 --- a/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Shifts/Shifts.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Видалити статичну зміну diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.de.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.de.resx index d99290cba..9991d7a4c 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.es.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.es.resx index e97154c42..01e553982 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.es.resx @@ -465,4 +465,7 @@ Administre su suscripción + + En el cuadro numérico de abajo, indique la cantidad de paquetes de 10 usuarios simultáneos de Push-To-Talk que desea comprar mensualmente. Si aumenta la cantidad, se le facturará de inmediato; si la reduce, su renovación será por la cantidad reducida. Si la establece en cero, no se le facturará (PTT no se renovará). Para esta suscripción del complemento PTT se usará la tarjeta registrada de su suscripción estándar de Resgrid. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.fr.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.fr.resx index 9a938580b..d4c96ea5b 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.it.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.it.resx index 4d61aa5ad..1c776b3ff 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.pl.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.pl.resx index 12a37d1d2..d9d6ef045 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.sv.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.sv.resx index 523a30227..4c46e1dcb 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.uk.resx b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.uk.resx index 30d7d1f80..bd4ed8bb3 100644 --- a/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Subscription/Subscription.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Billing Update Success diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx index 690acb42e..d7a653f02 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx @@ -153,4 +153,25 @@ أقر بأن التفاصيل المتاحة تغادر Resgrid وقد تحتفظ بها شركات الاتصالات أو المستلمون. السماح بالمحتوى المحمي طلب موظفو Resgrid الوصول إلى البلاغ المحمي {0} لحالة الدعم {1}. يتطلب الوصول موافقة موظف مستقل وتنتهي صلاحيته خلال 15 دقيقة. + تغيير أمني في حسابك على Resgrid + مرحبًا {0}، + مرحبًا، + تم إعداد تطبيق مصادقة لتسجيل الدخول بخطوتين في حسابك. + تم استبدال تطبيق المصادقة في حسابك، وتم إنهاء جميع الجلسات المسجّل الدخول بها. + تمت إزالة تطبيق المصادقة من حسابك، لذا أصبح تسجيل الدخول بخطوتين متوقفًا. وتم إنهاء جميع الجلسات المسجّل الدخول بها. + تم إنشاء رموز استرداد جديدة لحسابك. لم تعد الرموز القديمة تعمل. + تم استخدام أحد رموز الاسترداد الخاصة بك لتسجيل الدخول أو لبدء استرداد الحساب. + تمت إضافة مفتاح مرور جديد إلى حسابك. + تمت إزالة مفتاح مرور من حسابك. + أصبح بإمكان مفتاح مرور في Resgrid Responder الآن الموافقة على تسجيلات الدخول إلى تطبيقات Resgrid الأخرى لديك. + لم يعد بإمكان مفتاح مرور في Resgrid Responder الموافقة على تسجيلات الدخول إلى تطبيقات Resgrid الأخرى لديك. + أشرت في Resgrid Responder إلى أنك لم تطلب تسجيل الدخول. تم إيقاف تسجيل الدخول هذا، وتم إيقاف طلبات الموافقة مؤقتًا لمدة 15 دقيقة. قد يعرف شخص ما كلمة مرورك، لذا غيّرها الآن. + تم رفض عدة طلبات موافقة متتالية أو لم تتم الإجابة عنها، لذا تم إيقاف طلبات الموافقة مؤقتًا لمدة 15 دقيقة. + تم استرداد حسابك باستخدام رمز استرداد. تم إعداد تطبيق مصادقة جديد، وإنشاء رموز استرداد جديدة، وإنهاء جميع الجلسات المسجّل الدخول بها. + تم إعداد تطبيق مصادقة أو مفتاح مرور على جهاز لوحي مشترك في مركبة أو على محطة عمل مشتركة. إذا كان من الممكن أن يكون شخص آخر قد رأى رمز الإعداد أو يمكنه استخدام ذلك الجهاز، فاستبدل تطبيق المصادقة أو أزِل مفتاح المرور من جهازك الشخصي. + تم الإبلاغ عن عملية تحقق في حسابك على أنها لم تتم بواسطتك. إذا تم بها تسجيل الدخول على جهاز آخر، فقد تم إنهاء تلك الجلسة. غيّر كلمة المرور وراجع طرق تسجيل الدخول الخاصة بك. + الوقت: {0} UTC + المكان: {0} + إذا لم تكن أنت، فأعد تعيين كلمة المرور الآن على {0}، ثم سجّل الدخول وراجع طرق تسجيل الدخول الخاصة بك. لا يطلب Resgrid أبدًا رمزًا أو كلمة مرور عبر البريد الإلكتروني. + هذا إشعار أمني تلقائي بشأن حسابك على Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx index 4ad1c2d87..d4627b65b 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx @@ -153,4 +153,25 @@ Ihr Resgrid-Team Ich bestätige, dass freigegebene Details Resgrid verlassen und von Telefonanbietern oder Empfängern gespeichert werden können. Geschützte Inhalte erlauben Resgrid-Mitarbeiter haben Zugriff auf den geschützten Einsatz {0} für Supportfall {1} angefordert. Der Zugriff erfordert einen unabhängigen Genehmiger und läuft innerhalb von 15 Minuten ab. + Sicherheitsänderung an Ihrem Resgrid-Konto + Hallo {0}, + Hallo, + Für die zweistufige Anmeldung an Ihrem Konto wurde eine Authentifizierungs-App eingerichtet. + Die Authentifizierungs-App Ihres Kontos wurde ersetzt, und alle angemeldeten Sitzungen wurden beendet. + Die Authentifizierungs-App wurde aus Ihrem Konto entfernt, daher ist die zweistufige Anmeldung ausgeschaltet. Alle angemeldeten Sitzungen wurden beendet. + Für Ihr Konto wurden neue Wiederherstellungscodes erstellt. Die alten Codes funktionieren nicht mehr. + Einer Ihrer Wiederherstellungscodes wurde zur Anmeldung oder zum Start einer Kontowiederherstellung verwendet. + Ihrem Konto wurde ein neuer Passkey hinzugefügt. + Ein Passkey wurde aus Ihrem Konto entfernt. + Ein Passkey in Resgrid Responder kann jetzt Anmeldungen bei Ihren anderen Resgrid-Apps genehmigen. + Ein Passkey in Resgrid Responder kann keine Anmeldungen bei Ihren anderen Resgrid-Apps mehr genehmigen. + Sie haben in Resgrid Responder angegeben, dass Sie keine Anmeldung angefordert haben. Diese Anmeldung wurde gestoppt, und Genehmigungsanfragen sind für 15 Minuten pausiert. Möglicherweise kennt jemand Ihr Passwort – ändern Sie es jetzt. + Mehrere Genehmigungsanfragen hintereinander wurden abgelehnt oder nicht beantwortet, daher sind Genehmigungsanfragen für 15 Minuten pausiert. + Ihr Konto wurde mit einem Wiederherstellungscode wiederhergestellt. Eine neue Authentifizierungs-App wurde eingerichtet, neue Wiederherstellungscodes wurden erstellt, und alle angemeldeten Sitzungen wurden beendet. + Auf einem gemeinsam genutzten Fahrzeugtablet oder Arbeitsplatz wurde eine Authentifizierungs-App oder ein Passkey eingerichtet. Wenn jemand anderes den Einrichtungscode gesehen haben könnte oder dieses Gerät verwenden kann, ersetzen Sie die Authentifizierungs-App oder entfernen Sie den Passkey von Ihrem eigenen Gerät aus. + Eine Bestätigung in Ihrem Konto wurde als nicht von Ihnen stammend gemeldet. Wenn damit ein anderes Gerät angemeldet wurde, wurde diese Sitzung beendet. Ändern Sie Ihr Passwort und überprüfen Sie Ihre Anmeldemethoden. + Zeitpunkt: {0} UTC + Ort: {0} + Wenn Sie das nicht waren, setzen Sie Ihr Passwort jetzt unter {0} zurück, melden Sie sich dann an und prüfen Sie Ihre Anmeldemethoden. Resgrid fragt niemals per E-Mail nach einem Code oder Passwort. + Dies ist ein automatischer Sicherheitshinweis zu Ihrem Resgrid-Konto. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx index 2acbc5b96..158e7fd6b 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx @@ -153,4 +153,25 @@ Αναγνωρίζω ότι τα παρεχόμενα στοιχεία εξέρχονται από το Resgrid και μπορεί να διατηρηθούν από τηλεφωνικούς παρόχους ή παραλήπτες. Να επιτρέπεται προστατευμένο περιεχόμενο Το προσωπικό Resgrid ζήτησε πρόσβαση στο προστατευμένο συμβάν {0} για την υπόθεση υποστήριξης {1}. Η πρόσβαση απαιτεί ανεξάρτητη έγκριση και λήγει εντός 15 λεπτών. + Αλλαγή ασφαλείας στον λογαριασμό σας Resgrid + Γεια σας {0}, + Γεια σας, + Ρυθμίστηκε εφαρμογή ελέγχου ταυτότητας για σύνδεση δύο βημάτων στον λογαριασμό σας. + Η εφαρμογή ελέγχου ταυτότητας του λογαριασμού σας αντικαταστάθηκε και όλες οι ενεργές συνεδρίες τερματίστηκαν. + Η εφαρμογή ελέγχου ταυτότητας αφαιρέθηκε από τον λογαριασμό σας, οπότε η σύνδεση δύο βημάτων είναι απενεργοποιημένη. Όλες οι ενεργές συνεδρίες τερματίστηκαν. + Δημιουργήθηκαν νέοι κωδικοί ανάκτησης για τον λογαριασμό σας. Οι παλιοί κωδικοί δεν λειτουργούν πλέον. + Ένας από τους κωδικούς ανάκτησής σας χρησιμοποιήθηκε για σύνδεση ή για έναρξη ανάκτησης λογαριασμού. + Προστέθηκε νέο κλειδί πρόσβασης στον λογαριασμό σας. + Αφαιρέθηκε ένα κλειδί πρόσβασης από τον λογαριασμό σας. + Ένα κλειδί πρόσβασης στο Resgrid Responder μπορεί τώρα να εγκρίνει συνδέσεις στις άλλες εφαρμογές σας Resgrid. + Ένα κλειδί πρόσβασης στο Resgrid Responder δεν μπορεί πλέον να εγκρίνει συνδέσεις στις άλλες εφαρμογές σας Resgrid. + Δηλώσατε στο Resgrid Responder ότι δεν ζητήσατε σύνδεση. Αυτή η σύνδεση σταμάτησε και τα αιτήματα έγκρισης έχουν ανασταλεί για 15 λεπτά. Κάποιος ίσως γνωρίζει τον κωδικό πρόσβασής σας, γι' αυτό αλλάξτε τον τώρα. + Αρκετά διαδοχικά αιτήματα έγκρισης απορρίφθηκαν ή έμειναν αναπάντητα, γι' αυτό τα αιτήματα έγκρισης έχουν ανασταλεί για 15 λεπτά. + Ο λογαριασμός σας ανακτήθηκε με κωδικό ανάκτησης. Ρυθμίστηκε νέα εφαρμογή ελέγχου ταυτότητας, δημιουργήθηκαν νέοι κωδικοί ανάκτησης και όλες οι ενεργές συνεδρίες τερματίστηκαν. + Ρυθμίστηκε εφαρμογή ελέγχου ταυτότητας ή κλειδί πρόσβασης σε κοινόχρηστο tablet οχήματος ή σταθμό εργασίας. Αν κάποιος άλλος μπορεί να είδε τον κωδικό ρύθμισης ή μπορεί να χρησιμοποιήσει αυτή τη συσκευή, αντικαταστήστε την εφαρμογή ελέγχου ταυτότητας ή αφαιρέστε το κλειδί πρόσβασης από τη δική σας συσκευή. + Μια επαλήθευση στον λογαριασμό σας αναφέρθηκε ότι δεν έγινε από εσάς. Αν με αυτήν συνδέθηκε άλλη συσκευή, η συγκεκριμένη σύνδεση τερματίστηκε. Αλλάξτε τον κωδικό πρόσβασής σας και ελέγξτε τις μεθόδους σύνδεσής σας. + Πότε: {0} UTC + Πού: {0} + Αν δεν ήσασταν εσείς, επαναφέρετε τώρα τον κωδικό πρόσβασής σας στο {0}, έπειτα συνδεθείτε και ελέγξτε τις μεθόδους σύνδεσής σας. Το Resgrid δεν ζητά ποτέ κωδικό ή κωδικό πρόσβασης μέσω email. + Αυτή είναι μια αυτόματη ειδοποίηση ασφαλείας για τον λογαριασμό σας Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx index 29b287066..cf766ce83 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx @@ -153,4 +153,25 @@ The Resgrid Team I acknowledge that released details leave Resgrid and may be retained by phone carriers or recipients. Allow protected content Resgrid staff requested access to protected dispatch {0} for support case {1}. Access requires an independent staff approver and expires within 15 minutes. + Security change on your Resgrid account + Hi {0}, + Hi, + An authenticator app was set up for two-step sign-in on your account. + The authenticator app on your account was replaced, and every signed-in session was ended. + The authenticator app was removed from your account, so two-step sign-in is off. Every signed-in session was ended. + New recovery codes were created for your account. The old codes no longer work. + One of your recovery codes was used to sign in or to start account recovery. + A new passkey was added to your account. + A passkey was removed from your account. + A passkey in Resgrid Responder can now approve sign-ins to your other Resgrid apps. + A passkey in Resgrid Responder can no longer approve sign-ins to your other Resgrid apps. + You told Resgrid Responder that you did not ask to sign in. That sign-in was stopped, and approval requests are paused for 15 minutes. Someone may know your password, so change it now. + Several approval requests in a row were denied or went unanswered, so approval requests are paused for 15 minutes. + Your account was recovered with a recovery code. A new authenticator app was set up, new recovery codes were created, and every signed-in session was ended. + An authenticator app or passkey was set up on a shared vehicle tablet or workstation. If anyone else could have seen its setup code or can use that device, replace the authenticator app or remove the passkey from your own device. + A verification on your account was reported as not yours. If it signed in on another device, that session was ended. Change your password and review your sign-in methods. + When: {0} UTC + Where: {0} + If this wasn't you, reset your password now at {0}, then sign in and review your sign-in methods. Resgrid never asks for a code or password by email. + This is an automatic security notice about your Resgrid account. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx index a6ca79cbd..04603d3ef 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx @@ -153,4 +153,25 @@ El equipo de Resgrid Reconozco que los detalles entregados salen de Resgrid y pueden ser conservados por operadores telefónicos o destinatarios. Permitir contenido protegido El personal de Resgrid solicitó acceso al despacho protegido {0} para el caso de soporte {1}. El acceso requiere un aprobador independiente y caduca en 15 minutos. + Cambio de seguridad en su cuenta de Resgrid + Hola {0}: + Hola: + Se configuró una aplicación de autenticación para el inicio de sesión en dos pasos de su cuenta. + Se reemplazó la aplicación de autenticación de su cuenta y se cerraron todas las sesiones iniciadas. + Se quitó la aplicación de autenticación de su cuenta, por lo que el inicio de sesión en dos pasos está desactivado. Se cerraron todas las sesiones iniciadas. + Se crearon nuevos códigos de recuperación para su cuenta. Los códigos anteriores ya no funcionan. + Se usó uno de sus códigos de recuperación para iniciar sesión o para comenzar la recuperación de la cuenta. + Se agregó una nueva clave de acceso a su cuenta. + Se quitó una clave de acceso de su cuenta. + Una clave de acceso en Resgrid Responder ahora puede aprobar inicios de sesión en sus otras aplicaciones de Resgrid. + Una clave de acceso en Resgrid Responder ya no puede aprobar inicios de sesión en sus otras aplicaciones de Resgrid. + Usted indicó en Resgrid Responder que no solicitó iniciar sesión. Ese inicio de sesión se detuvo y las solicitudes de aprobación están en pausa durante 15 minutos. Es posible que alguien conozca su contraseña: cámbiela ahora. + Varias solicitudes de aprobación seguidas se rechazaron o no se respondieron, por lo que las solicitudes de aprobación están en pausa durante 15 minutos. + Su cuenta se recuperó con un código de recuperación. Se configuró una nueva aplicación de autenticación, se crearon nuevos códigos de recuperación y se cerraron todas las sesiones iniciadas. + Se configuró una aplicación de autenticación o una clave de acceso en una tableta de vehículo o un puesto de trabajo compartido. Si otra persona pudo ver el código de configuración o puede usar ese dispositivo, reemplace la aplicación de autenticación o quite la clave de acceso desde su propio dispositivo. + Se informó que una verificación en su cuenta no la realizó usted. Si con ella se inició sesión en otro dispositivo, esa sesión se cerró. Cambie su contraseña y revise sus métodos de inicio de sesión. + Cuándo: {0} UTC + Dónde: {0} + Si no fue usted, restablezca su contraseña ahora en {0}, luego inicie sesión y revise sus métodos de inicio de sesión. Resgrid nunca le pedirá un código ni una contraseña por correo electrónico. + Este es un aviso de seguridad automático sobre su cuenta de Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx index 611abeb2b..39af3c3fe 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx @@ -153,4 +153,25 @@ L'équipe Resgrid Je reconnais que les détails transmis quittent Resgrid et peuvent être conservés par les opérateurs téléphoniques ou les destinataires. Autoriser le contenu protégé Le personnel Resgrid a demandé l’accès à l’intervention protégée {0} pour le dossier d’assistance {1}. L’accès nécessite un approbateur indépendant et expire sous 15 minutes. + Modification de sécurité sur votre compte Resgrid + Bonjour {0}, + Bonjour, + Une application d'authentification a été configurée pour la connexion en deux étapes de votre compte. + L'application d'authentification de votre compte a été remplacée et toutes les sessions ouvertes ont été fermées. + L'application d'authentification a été retirée de votre compte : la connexion en deux étapes est désactivée. Toutes les sessions ouvertes ont été fermées. + De nouveaux codes de récupération ont été créés pour votre compte. Les anciens codes ne fonctionnent plus. + L'un de vos codes de récupération a été utilisé pour vous connecter ou pour lancer la récupération du compte. + Une nouvelle clé d'accès a été ajoutée à votre compte. + Une clé d'accès a été retirée de votre compte. + Une clé d'accès dans Resgrid Responder peut désormais approuver les connexions à vos autres applications Resgrid. + Une clé d'accès dans Resgrid Responder ne peut plus approuver les connexions à vos autres applications Resgrid. + Vous avez indiqué dans Resgrid Responder que vous n'aviez pas demandé à vous connecter. Cette connexion a été arrêtée et les demandes d'approbation sont suspendues pendant 15 minutes. Quelqu'un connaît peut-être votre mot de passe : changez-le dès maintenant. + Plusieurs demandes d'approbation consécutives ont été refusées ou sont restées sans réponse ; les demandes d'approbation sont donc suspendues pendant 15 minutes. + Votre compte a été récupéré avec un code de récupération. Une nouvelle application d'authentification a été configurée, de nouveaux codes de récupération ont été créés et toutes les sessions ouvertes ont été fermées. + Une application d'authentification ou une clé d'accès a été configurée sur une tablette de véhicule ou un poste de travail partagé. Si quelqu'un d'autre a pu voir le code de configuration ou peut utiliser cet appareil, remplacez l'application d'authentification ou supprimez la clé d'accès depuis votre propre appareil. + Une vérification sur votre compte a été signalée comme ne venant pas de vous. Si elle a ouvert une session sur un autre appareil, cette session a été fermée. Changez votre mot de passe et vérifiez vos méthodes de connexion. + Quand : {0} UTC + Où : {0} + Si ce n'était pas vous, réinitialisez votre mot de passe dès maintenant sur {0}, puis connectez-vous et vérifiez vos méthodes de connexion. Resgrid ne vous demande jamais de code ni de mot de passe par e-mail. + Ceci est un avis de sécurité automatique concernant votre compte Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx index 13a60823b..ef3577054 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx @@ -153,4 +153,25 @@ Il team Resgrid Riconosco che i dettagli rilasciati escono da Resgrid e possono essere conservati dagli operatori telefonici o dai destinatari. Consenti contenuti protetti Il personale Resgrid ha richiesto accesso all’intervento protetto {0} per la richiesta di assistenza {1}. L’accesso richiede un approvatore indipendente e scade entro 15 minuti. + Modifica di sicurezza sul tuo account Resgrid + Ciao {0}, + Ciao, + È stata configurata un'app di autenticazione per l'accesso in due passaggi al tuo account. + L'app di autenticazione del tuo account è stata sostituita e tutte le sessioni aperte sono state chiuse. + L'app di autenticazione è stata rimossa dal tuo account, quindi l'accesso in due passaggi è disattivato. Tutte le sessioni aperte sono state chiuse. + Sono stati creati nuovi codici di recupero per il tuo account. I vecchi codici non funzionano più. + Uno dei tuoi codici di recupero è stato usato per accedere o per avviare il recupero dell'account. + Al tuo account è stata aggiunta una nuova passkey. + Una passkey è stata rimossa dal tuo account. + Una passkey in Resgrid Responder ora può approvare gli accessi alle tue altre app Resgrid. + Una passkey in Resgrid Responder non può più approvare gli accessi alle tue altre app Resgrid. + Hai indicato in Resgrid Responder di non aver chiesto di accedere. Quell'accesso è stato bloccato e le richieste di approvazione sono sospese per 15 minuti. Qualcuno potrebbe conoscere la tua password: cambiala subito. + Diverse richieste di approvazione consecutive sono state rifiutate o non hanno avuto risposta, quindi le richieste di approvazione sono sospese per 15 minuti. + Il tuo account è stato recuperato con un codice di recupero. È stata configurata una nuova app di autenticazione, sono stati creati nuovi codici di recupero e tutte le sessioni aperte sono state chiuse. + Un'app di autenticazione o una passkey è stata configurata su un tablet di un veicolo o una postazione condivisa. Se qualcun altro potrebbe aver visto il codice di configurazione o può usare quel dispositivo, sostituisci l'app di autenticazione o rimuovi la passkey dal tuo dispositivo personale. + Una verifica sul tuo account è stata segnalata come non tua. Se ha effettuato l'accesso su un altro dispositivo, quella sessione è stata chiusa. Cambia la password e controlla i tuoi metodi di accesso. + Quando: {0} UTC + Dove: {0} + Se non sei stato tu, reimposta subito la password su {0}, poi accedi e controlla i tuoi metodi di accesso. Resgrid non chiede mai codici o password via e-mail. + Questo è un avviso di sicurezza automatico sul tuo account Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx index aeede04c2..42bd82651 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx @@ -153,4 +153,25 @@ Zespół Resgrid Przyjmuję do wiadomości, że udostępnione szczegóły opuszczają Resgrid i mogą być przechowywane przez operatorów telefonicznych lub odbiorców. Zezwól na chronioną treść Pracownik Resgrid poprosił o dostęp do chronionego zgłoszenia {0} w sprawie pomocy {1}. Dostęp wymaga niezależnego zatwierdzenia i wygasa w ciągu 15 minut. + Zmiana zabezpieczeń na Twoim koncie Resgrid + Cześć {0}, + Cześć, + Skonfigurowano aplikację uwierzytelniającą do dwuetapowego logowania na Twoim koncie. + Aplikacja uwierzytelniająca na Twoim koncie została zastąpiona, a wszystkie zalogowane sesje zostały zakończone. + Aplikacja uwierzytelniająca została usunięta z Twojego konta, więc logowanie dwuetapowe jest wyłączone. Wszystkie zalogowane sesje zostały zakończone. + Utworzono nowe kody odzyskiwania dla Twojego konta. Stare kody już nie działają. + Jeden z Twoich kodów odzyskiwania został użyty do zalogowania się lub do rozpoczęcia odzyskiwania konta. + Do Twojego konta dodano nowy klucz dostępu. + Z Twojego konta usunięto klucz dostępu. + Klucz dostępu w aplikacji Resgrid Responder może teraz zatwierdzać logowania do Twoich innych aplikacji Resgrid. + Klucz dostępu w aplikacji Resgrid Responder nie może już zatwierdzać logowań do Twoich innych aplikacji Resgrid. + W aplikacji Resgrid Responder wskazano, że nie próbowałeś się logować. To logowanie zostało zatrzymane, a prośby o zatwierdzenie są wstrzymane na 15 minut. Ktoś może znać Twoje hasło – zmień je teraz. + Kilka kolejnych próśb o zatwierdzenie zostało odrzuconych lub pozostało bez odpowiedzi, więc prośby o zatwierdzenie są wstrzymane na 15 minut. + Twoje konto zostało odzyskane za pomocą kodu odzyskiwania. Skonfigurowano nową aplikację uwierzytelniającą, utworzono nowe kody odzyskiwania, a wszystkie zalogowane sesje zostały zakończone. + Aplikacja uwierzytelniająca lub klucz dostępu zostały skonfigurowane na współdzielonym tablecie w pojeździe lub stanowisku pracy. Jeśli ktoś inny mógł zobaczyć kod konfiguracji lub może używać tego urządzenia, zastąp aplikację uwierzytelniającą albo usuń klucz dostępu ze swojego urządzenia. + Weryfikacja na Twoim koncie została zgłoszona jako niewykonana przez Ciebie. Jeśli posłużyła do zalogowania na innym urządzeniu, ta sesja została zakończona. Zmień hasło i sprawdź swoje metody logowania. + Kiedy: {0} UTC + Gdzie: {0} + Jeśli to nie Ty, zresetuj teraz hasło na stronie {0}, a następnie zaloguj się i sprawdź swoje metody logowania. Resgrid nigdy nie prosi o kod ani hasło przez e-mail. + To jest automatyczne powiadomienie o bezpieczeństwie Twojego konta Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx index 128f72a52..68d1cf76d 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx @@ -153,4 +153,25 @@ Resgrid-teamet Jag bekräftar att utlämnade uppgifter lämnar Resgrid och kan sparas av telefonoperatörer eller mottagare. Tillåt skyddat innehåll Resgrids personal har begärt åtkomst till det skyddade larmet {0} för supportärende {1}. Åtkomst kräver en oberoende godkännare och upphör inom 15 minuter. + Säkerhetsändring på ditt Resgrid-konto + Hej {0}, + Hej, + En autentiseringsapp har konfigurerats för tvåstegsinloggning på ditt konto. + Autentiseringsappen på ditt konto har bytts ut, och alla inloggade sessioner har avslutats. + Autentiseringsappen har tagits bort från ditt konto, så tvåstegsinloggning är avstängd. Alla inloggade sessioner har avslutats. + Nya återställningskoder har skapats för ditt konto. De gamla koderna fungerar inte längre. + En av dina återställningskoder användes för att logga in eller för att starta kontoåterställning. + En ny nyckel (passkey) har lagts till på ditt konto. + En nyckel (passkey) har tagits bort från ditt konto. + En nyckel (passkey) i Resgrid Responder kan nu godkänna inloggningar i dina andra Resgrid-appar. + En nyckel (passkey) i Resgrid Responder kan inte längre godkänna inloggningar i dina andra Resgrid-appar. + Du angav i Resgrid Responder att du inte bad om att logga in. Den inloggningen stoppades, och godkännandeförfrågningar är pausade i 15 minuter. Någon kan känna till ditt lösenord, så byt det nu. + Flera godkännandeförfrågningar i rad nekades eller besvarades inte, så godkännandeförfrågningar är pausade i 15 minuter. + Ditt konto har återställts med en återställningskod. En ny autentiseringsapp har konfigurerats, nya återställningskoder har skapats och alla inloggade sessioner har avslutats. + En autentiseringsapp eller nyckel (passkey) har konfigurerats på en delad fordonsplatta eller arbetsstation. Om någon annan kan ha sett konfigurationskoden eller kan använda enheten, byt ut autentiseringsappen eller ta bort nyckeln från din egen enhet. + En verifiering på ditt konto rapporterades som inte gjord av dig. Om den loggade in på en annan enhet har den sessionen avslutats. Byt lösenord och se över dina inloggningsmetoder. + När: {0} UTC + Var: {0} + Om det inte var du, återställ ditt lösenord nu på {0}, logga sedan in och granska dina inloggningsmetoder. Resgrid ber aldrig om en kod eller ett lösenord via e-post. + Detta är ett automatiskt säkerhetsmeddelande om ditt Resgrid-konto. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx index 953fa62fa..04b2de569 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx @@ -153,4 +153,25 @@ Я підтверджую, що надані відомості залишають Resgrid і можуть зберігатися телефонними операторами або одержувачами. Дозволити захищений вміст Працівник Resgrid запросив доступ до захищеного виклику {0} для звернення до підтримки {1}. Доступ потребує незалежного схвалення та діє не більше 15 хвилин. + Зміна безпеки у вашому обліковому записі Resgrid + Вітаємо, {0}! + Вітаємо! + Для двоетапного входу у ваш обліковий запис налаштовано застосунок автентифікації. + Застосунок автентифікації вашого облікового запису замінено, а всі активні сеанси завершено. + Застосунок автентифікації видалено з вашого облікового запису, тому двоетапний вхід вимкнено. Усі активні сеанси завершено. + Для вашого облікового запису створено нові коди відновлення. Старі коди більше не діють. + Один із ваших кодів відновлення використано для входу або для початку відновлення облікового запису. + До вашого облікового запису додано новий ключ доступу. + З вашого облікового запису видалено ключ доступу. + Ключ доступу в Resgrid Responder тепер може підтверджувати входи у ваші інші застосунки Resgrid. + Ключ доступу в Resgrid Responder більше не може підтверджувати входи у ваші інші застосунки Resgrid. + Ви вказали в Resgrid Responder, що не запитували вхід. Цей вхід зупинено, а запити на підтвердження призупинено на 15 хвилин. Можливо, хтось знає ваш пароль, тож змініть його зараз. + Кілька запитів на підтвердження поспіль було відхилено або залишено без відповіді, тому запити на підтвердження призупинено на 15 хвилин. + Ваш обліковий запис відновлено за допомогою коду відновлення. Налаштовано новий застосунок автентифікації, створено нові коди відновлення, а всі активні сеанси завершено. + На спільному планшеті в транспортному засобі або робочій станції налаштовано застосунок автентифікації чи ключ доступу. Якщо хтось інший міг бачити код налаштування або може користуватися цим пристроєм, замініть застосунок автентифікації або видаліть ключ доступу зі свого власного пристрою. + Про перевірку у вашому обліковому записі повідомлено, що її виконали не ви. Якщо з її допомогою було здійснено вхід на іншому пристрої, цей сеанс завершено. Змініть пароль і перегляньте свої способи входу. + Коли: {0} UTC + Де: {0} + Якщо це були не ви, негайно скиньте пароль на {0}, а потім увійдіть і перевірте свої способи входу. Resgrid ніколи не просить код або пароль електронною поштою. + Це автоматичне сповіщення про безпеку вашого облікового запису Resgrid. \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx index 741b29be2..e21646576 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Anrufnotizvorlage hinzufügen diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx index 89e75e883..d6ba08e72 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Ajouter un modèle de note d'appel diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx index abdc19f2a..372c961b5 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Aggiungi modello di nota chiamata diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx index 3c199f562..1ece2a481 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Dodaj szablon notatki zgłoszenia diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx index 1390103f0..bd69b19b0 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Lägg till samtalsnotatmall diff --git a/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx b/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx index 4d0daff16..1fef1e1a8 100644 --- a/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Templates/Templates.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Додати шаблон нотатки виклику diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx index e80cd3736..93175fe39 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.ar.resx @@ -37,6 +37,7 @@ تم تعطيل المصادقة الثنائية سيقلل من أمان حسابك. سيتم إعادة تعيين مفتاح المصادقة الخاص بك. أدخل رمز المصادقة الحالي لتأكيد رغبتك في تعطيل المصادقة الثنائية: + لا يمكنك تعطيل المصادقة الثنائية ما دامت لديك مفاتيح مرور. أزِل مفاتيح المرور أولاً، ثم عطّل المصادقة الثنائية. رمز المصادقة تعطيل المصادقة الثنائية تأكيد هويتك @@ -56,4 +57,409 @@ استخدام تطبيق المصادقة بدلاً من ذلك رمز المصادقة غير صالح. رمز الاسترداد غير صالح. + Replace Authenticator + Replace Your Authenticator App + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + Verify, Replace and Sign Out Everywhere + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + Sign In Again + + مفاتيح المرور + + + يتحقق مفتاح المرور من هويتك باستخدام قفل شاشة جهازك أو مفتاح أمان. يعمل كل مفتاح مرور فقط في التطبيق الذي أُضيف فيه؛ أضف مفتاحًا هنا لموقع الويب. تؤدي إزالة مفتاح المرور إلى إيقاف عمله في Resgrid فورًا، وإن كان قد يبقى على الجهاز أو في مدير كلمات المرور لديك. + + + ليس لديك أي مفاتيح مرور بعد. + + + إضافة مفتاح مرور للويب + + + إضافة مفاتيح المرور للويب غير متاحة في هذا النشر بعد. + + + أعدّ تطبيق مصادقة واحتفظ برموز الاسترداد قبل إضافة مفتاح مرور. + + + الاسم + + + يعمل في + + + تاريخ الإضافة + + + آخر استخدام + + + لم يُستخدم بعد + + + أُضيف على تثبيت مشترك + + + إعادة تسمية + + + إزالة + + + الاسم الجديد لمفتاح المرور هذا: + + + هل تريد إزالة مفتاح المرور هذا؟ سيتوقف عن العمل في Resgrid فورًا، وستنتهي الجلسات التي سُجّل الدخول إليها باستخدامه. + + + تمت إضافة مفتاح المرور. + + + تمت إزالة مفتاح المرور. + + + تم إغلاق نافذة مفتاح المرور. لم يتغير شيء. + + + لا يمكن لهذا المتصفح استخدام مفاتيح المرور. استخدم تطبيق المصادقة بدلًا من ذلك. + + + تعذر استخدام مفتاح المرور. حاول مرة أخرى أو استخدم تطبيق المصادقة. + + + لديك بالفعل الحد الأقصى المسموح به من مفاتيح المرور للويب. أزل واحدًا أولًا. + + + استخدام مفتاح مرور + + + الويب + + + Responder + + + Unit + + + Dispatch + + + IC + + + تمت إعادة تسمية مفتاح المرور. + + + يحتوي هذا الجهاز أو مفتاح الأمان بالفعل على مفتاح مرور لحسابك في هذا التطبيق. + + + تم قبول كلمة المرور. أكمل تسجيل الدخول باستخدام إحدى طرق التحقق لديك. + + + الموافقة عبر Responder + + + افتح Responder على هاتفك ووافق على الطلب. تأكد من أنه يعرض هذا الرقم: + + + في انتظار موافقتك في Responder… + + + تم رفض الطلب في Responder. استخدم طريقة أخرى. + + + انتهى الطلب قبل الموافقة عليه. ابدأ من جديد. + + + إيقاف الانتظار + + + الموافقة عبر Responder غير متاحة الآن. استخدم طريقة أخرى. + + + لم ينجح ذلك. حاول مرة أخرى أو استخدم طريقة أخرى. + + + استغرق تسجيل الدخول وقتًا طويلاً. سجّل الدخول مرة أخرى. + + + محاولات فاشلة كثيرة جدًا. انتظر بضع دقائق ثم سجّل الدخول مرة أخرى. + + + تغيّرت سياسة تسجيل الدخول في إدارتك. سجّل الدخول مرة أخرى. + + + تغيّرت بيانات تسجيل الدخول لحسابك. سجّل الدخول مرة أخرى. + + + تسجيل الدخول غير متاح مؤقتًا. حاول مرة أخرى بعد قليل. + + + لم يعد تسجيل الدخول هذا صالحًا. سجّل الدخول مرة أخرى. + + + طريقة التحقق هذه غير متاحة لتسجيل الدخول هذا. استخدم طريقة أخرى. + + + تم بلوغ الحد الأقصى لعدد الجلسات النشطة في إدارتك. أنهِ إحدى جلساتك أو تواصل مع المسؤول. + + + سجّلت الدخول باستخدام رمز استرداد. إذا لم يعد تطبيق المصادقة لديك، فاستبدله الآن. + + + استخدام طريقة أخرى + + + تسجيل الدخول بالدخول الموحد + + + الدخول الموحد + + + أدخل اسم المستخدم أو رمز إدارتك. ستسجّل الدخول عبر موفر الهوية لدى مؤسستك. + + + اسم المستخدم + + + أو + + + رمز الإدارة + + + متابعة + + + تسجيل الدخول بكلمة مرور بدلاً من ذلك + + + أدخل اسم المستخدم أو رمز إدارتك. + + + الدخول الموحد غير متاح لتسجيل الدخول هذا. + + + تعذّرت مطابقة تسجيل الدخول هذا مع هذا المتصفح. ابدأ من جديد. + + + لم يسجّل موفر الهوية دخولك. + + + تعذّر إكمال الدخول الموحد. ابدأ من جديد. + + + استغرق الدخول الموحد وقتًا طويلاً. ابدأ من جديد. + + + لا يمكن لهذا الحساب تسجيل الدخول هنا بالدخول الموحد. + + + سجّل موفر الهوية الدخول لحساب مختلف. ابدأ من جديد بالحساب الذي تستخدمه هنا. + + + لم يؤكد موفر الهوية تسجيل دخول جديدًا. ابدأ من جديد. + + + لم يؤكد موفر الهوية المصادقة متعددة العوامل. استخدم طريقة أخرى. + + + تتطلب إدارتك المصادقة متعددة العوامل، ولا يملك هذا الحساب أيًا منها بعد. اسأل المسؤول عن كيفية إعدادها. + + + التحقق عبر موفر الهوية + + + التأكيد بالدخول الموحد + + + يتطلب اختبار الربط تحققًا حديثًا باستخدام تطبيق المصادقة أو مفتاح مرور. + + + لم يحمل تسجيل الدخول التجريبي قيمة يحتسبها الربط مصادقةً متعددة العوامل. تحقق من الربط وموفر الهوية ثم اختبر مرة أخرى. + + + تغيّر الربط أثناء الاختبار. اختبره مرة أخرى. + + + اجتاز الربط اختباره وأصبح ساريًا الآن. + + + إعداد تطبيق المصادقة + + + تتطلب إدارتك عاملاً ثانيًا، ولا يملك هذا الحساب أيًا منه بعد. أعدّ تطبيق مصادقة لإكمال تسجيل الدخول. + + + التحقق وتسجيل الدخول + + + انتهت صلاحية رمز الإعداد. يظهر رمز جديد؛ امسحه مرة أخرى. + + + العودة إلى تسجيل الدخول + + + فقدت أداة المصادقة + + + هل فقدت أداة المصادقة؟ + + + إذا حفظت رموز الاسترداد، فأدخل أحدها لإعداد تطبيق مصادقة جديد وإزالة مفاتيح المرور التي فقدتها. تُسجَّل كل الجلسات خروجًا، ثم تسجّل الدخول من جديد. + + + استبدال أداة المصادقة + + + لاستخدام رمز استرداد، سجّل الدخول أولاً بكلمة المرور أو الدخول الموحد، ثم اختر "فقدت أداة المصادقة". + + + لا يوجد رمز استرداد؟ + + + لا يمكن لـ Resgrid إعادة تعيين أداة مصادقة مفقودة تلقائيًا. اطلب من مسؤولي إدارتك التواصل مع دعم Resgrid، الذي يتحقق من هويتك مع إدارتك قبل تغيير أي شيء. واصل اتباع إجراءات إدارتك حتى ذلك الحين. + + + استبدال أداة المصادقة + + + أعدّ تطبيق المصادقة الجديد وأدخل الرمز الذي يعرضه. اختر أي مفاتيح مرور فقدتها؛ ستتم إزالتها. + + + مفاتيح المرور المراد إزالتها كمفقودة + + + الاستبدال وتسجيل الخروج من كل مكان + + + إلغاء الاسترداد + + + تم استبدال أداة المصادقة + + + تم تسجيل الخروج من كل الجلسات. احفظ رموز الاسترداد الجديدة، ثم سجّل الدخول من جديد باستخدام أداة المصادقة الجديدة. + + + تسجيل الدخول + + + لم يعد هذا الاسترداد صالحًا. سجّل الدخول وابدأ من جديد. + + + الاسترداد غير متاح مؤقتًا. سجّل الدخول وحاول مرة أخرى باستخدام رمز استرداد آخر. + + + أحد مفاتيح المرور المراد إزالتها ليس لك أو تمت إزالته بالفعل. + + + جارٍ العودة من صفحة تسجيل الدخول الخاصة بمؤسستك… + + + متابعة + + + محطة عمل مشتركة + + + في محطة العمل المشتركة، يُقفل كل تسجيل دخول في هذا المتصفح عند الخمول، وينتهي عند حد المناوبة، ولا يتذكر المتصفح أبدًا. يسجل كل مشغل الدخول بحسابه الخاص. + + + استخدام هذا المتصفح كمحطة عمل مشتركة + + + اسم محطة العمل (اختياري) + + + اسم للدعم، مثل "مكتب الإرسال 2". يحدد المحطة وليس شخصًا. + + + حفظ + + + أصبح هذا المتصفح الآن محطة عمل مشتركة. يسري ذلك من تسجيل الدخول التالي. + + + لم يعد هذا المتصفح محطة عمل مشتركة. يسري ذلك من تسجيل الدخول التالي. + + + وضع محطة العمل المشتركة غير متاح في نشر Resgrid هذا. + + + إعدادات محطة العمل المشتركة + + + محطة عمل مشتركة: تُقفل عمليات تسجيل الدخول هنا عند الخمول ولا تتذكر هذا المتصفح أبدًا. + + + محطة عمل مشتركة ({0}): تُقفل عمليات تسجيل الدخول هنا عند الخمول ولا تتذكر هذا المتصفح أبدًا. + + + محطة العمل مقفلة + + + أُقفلت محطة العمل هذه بسبب الخمول. + + + محطة العمل هذه مقفلة. + + + تم تسجيل الدخول باسم {0} + + + لا يمكن فتحها إلا لـ {0}. أي شخص آخر: بدّل المشغل أو أنهِ المناوبة. + + + فتح القفل + + + تبديل المشغل + + + إنهاء المناوبة + + + قفل + + + البقاء متصلاً + + + يُقفل خلال {0} + + + ستُقفل محطة العمل هذه خلال {0} ثانية بسبب الخمول. + + + تنتهي المناوبة خلال {0} دقيقة. + + + يتطلب الفتح السريع تطبيق مصادقة أو طريقة تحقق أخرى. أنهِ المناوبة وسجّل الدخول مجددًا. + + + يتطلب قسمك تسجيل الدخول الموحد. أنهِ المناوبة وسجّل الدخول عبر SSO. + + + أُقفلت محطة العمل مرة أخرى. افتح قفلها مجددًا. + + + انتهت هذه الجلسة. أنهِ المناوبة وسجّل الدخول مجددًا. + + + محاولات فتح كثيرة جدًا. انتظر بضع دقائق، أو أنهِ المناوبة وسجّل الدخول مجددًا. + + + تعذر تحديث الجلسة. حاول مرة أخرى. + + + انتهت المناوبة على محطة العمل المشتركة هذه. سجّل الدخول مجددًا. + + + انتهت المناوبة. محطة العمل هذه جاهزة لتسجيل الدخول التالي. + + + تم تسجيل الخروج. يمكن للمشغل التالي تسجيل الدخول الآن. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx index ed49a16cc..ee159e09a 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.de.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Sie können 2FA nicht deaktivieren, solange Sie Passkeys haben. Entfernen Sie zuerst Ihre Passkeys und deaktivieren Sie dann 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Passkeys + + + Ein Passkey bestätigt Sie über die Bildschirmsperre Ihres Geräts oder einen Sicherheitsschlüssel. Jeder Passkey funktioniert nur in der App, in der er hinzugefügt wurde; fügen Sie hier einen für das Web hinzu. Wenn Sie einen Passkey entfernen, funktioniert er bei Resgrid sofort nicht mehr, auch wenn er auf dem Gerät oder in Ihrem Passwort-Manager verbleiben kann. + + + Sie haben noch keine Passkeys. + + + Passkey für das Web hinzufügen + + + Das Hinzufügen von Passkeys für das Web ist in dieser Installation noch nicht verfügbar. + + + Richten Sie eine Authenticator-App ein und bewahren Sie Wiederherstellungscodes auf, bevor Sie einen Passkey hinzufügen. + + + Name + + + Funktioniert in + + + Hinzugefügt + + + Zuletzt verwendet + + + Noch nicht + + + Auf einer gemeinsam genutzten Installation hinzugefügt + + + Umbenennen + + + Entfernen + + + Neuer Name für diesen Passkey: + + + Diesen Passkey entfernen? Er funktioniert bei Resgrid sofort nicht mehr, und Sitzungen, die damit angemeldet wurden, werden beendet. + + + Passkey hinzugefügt. + + + Passkey entfernt. + + + Die Passkey-Abfrage wurde geschlossen. Es wurde nichts geändert. + + + Dieser Browser kann keine Passkeys verwenden. Verwenden Sie stattdessen Ihre Authenticator-App. + + + Der Passkey konnte nicht verwendet werden. Versuchen Sie es erneut oder verwenden Sie Ihre Authenticator-App. + + + Sie haben bereits die maximal zulässige Anzahl an Passkeys für das Web. Entfernen Sie zuerst einen. + + + Passkey verwenden + + + Web + + + Responder + + + Unit + + + Dispatch + + + IC + + + Passkey umbenannt. + + + Dieses Gerät oder dieser Sicherheitsschlüssel enthält bereits einen Passkey für Ihr Konto in dieser App. + + + Ihr Passwort wurde akzeptiert. Schließen Sie die Anmeldung mit einer Ihrer Bestätigungsmethoden ab. + + + Mit Responder bestätigen + + + Öffnen Sie Responder auf Ihrem Telefon und bestätigen Sie die Anfrage. Prüfen Sie, dass diese Zahl angezeigt wird: + + + Warten auf Ihre Bestätigung in Responder… + + + Die Anfrage wurde in Responder abgelehnt. Verwenden Sie eine andere Methode. + + + Die Anfrage ist abgelaufen, bevor sie bestätigt wurde. Beginnen Sie erneut. + + + Nicht mehr warten + + + Die Bestätigung mit Responder ist gerade nicht verfügbar. Verwenden Sie eine andere Methode. + + + Das hat nicht funktioniert. Versuchen Sie es erneut oder verwenden Sie eine andere Methode. + + + Ihre Anmeldung hat zu lange gedauert. Melden Sie sich erneut an. + + + Zu viele fehlgeschlagene Versuche. Warten Sie einige Minuten und melden Sie sich erneut an. + + + Die Anmelderichtlinie Ihrer Abteilung hat sich geändert. Melden Sie sich erneut an. + + + Die Anmeldedaten Ihres Kontos haben sich geändert. Melden Sie sich erneut an. + + + Die Anmeldung ist vorübergehend nicht verfügbar. Versuchen Sie es gleich noch einmal. + + + Diese Anmeldung ist nicht mehr gültig. Melden Sie sich erneut an. + + + Diese Bestätigungsmethode ist für diese Anmeldung nicht verfügbar. Verwenden Sie eine andere Methode. + + + Die maximale Anzahl aktiver Sitzungen Ihrer Abteilung ist erreicht. Beenden Sie eine Ihrer Sitzungen oder wenden Sie sich an Ihren Administrator. + + + Sie haben sich mit einem Wiederherstellungscode angemeldet. Wenn Sie Ihre Authenticator-App nicht mehr haben, ersetzen Sie sie jetzt. + + + Andere Methode verwenden + + + Mit Single Sign-On anmelden + + + Single Sign-On + + + Geben Sie Ihren Benutzernamen oder den Code Ihrer Abteilung ein. Sie melden sich beim Identitätsanbieter Ihrer Organisation an. + + + Benutzername + + + oder + + + Abteilungscode + + + Weiter + + + Stattdessen mit Passwort anmelden + + + Geben Sie Ihren Benutzernamen oder den Code Ihrer Abteilung ein. + + + Single Sign-On ist für diese Anmeldung nicht verfügbar. + + + Diese Anmeldung konnte diesem Browser nicht zugeordnet werden. Beginnen Sie erneut. + + + Ihr Identitätsanbieter hat Sie nicht angemeldet. + + + Single Sign-On konnte nicht abgeschlossen werden. Beginnen Sie erneut. + + + Das Single Sign-On hat zu lange gedauert. Beginnen Sie erneut. + + + Dieses Konto kann sich hier nicht mit Single Sign-On anmelden. + + + Ihr Identitätsanbieter hat ein anderes Konto angemeldet. Beginnen Sie erneut mit dem Konto, das Sie hier verwenden. + + + Ihr Identitätsanbieter hat keine neue Anmeldung bestätigt. Beginnen Sie erneut. + + + Ihr Identitätsanbieter hat keine Multi-Faktor-Authentifizierung bestätigt. Verwenden Sie eine andere Methode. + + + Ihre Abteilung verlangt Multi-Faktor-Authentifizierung, und dieses Konto hat noch keine. Fragen Sie Ihren Administrator, wie Sie sie einrichten. + + + Mit Ihrem Identitätsanbieter bestätigen + + + Mit Single Sign-On bestätigen + + + Zum Testen der Zuordnung ist eine aktuelle Bestätigung mit Ihrer Authenticator-App oder einem Passkey nötig. + + + Die Testanmeldung enthielt keinen Wert, den die Zuordnung als MFA zählt. Prüfen Sie die Zuordnung und Ihren Identitätsanbieter und testen Sie erneut. + + + Die Zuordnung hat sich während des Tests geändert. Testen Sie sie erneut. + + + Die Zuordnung hat ihren Test bestanden und ist jetzt wirksam. + + + Authenticator-App einrichten + + + Ihre Abteilung verlangt einen zweiten Faktor, und dieses Konto hat noch keinen. Richten Sie eine Authenticator-App ein, um die Anmeldung abzuschließen. + + + Bestätigen und anmelden + + + Der Einrichtungscode ist abgelaufen. Ein neuer wird angezeigt; scannen Sie ihn erneut. + + + Zurück zur Anmeldung + + + Ich habe meinen Authenticator verloren + + + Authenticator verloren? + + + Wenn Sie Ihre Wiederherstellungscodes gespeichert haben, geben Sie einen ein, um eine neue Authenticator-App einzurichten und verlorene Passkeys zu entfernen. Alle Sitzungen werden abgemeldet; danach melden Sie sich erneut an. + + + Authenticator ersetzen + + + Um einen Wiederherstellungscode zu verwenden, melden Sie sich zuerst mit Ihrem Passwort oder Single Sign-On an und wählen Sie dann „Ich habe meinen Authenticator verloren“. + + + Kein Wiederherstellungscode? + + + Resgrid kann einen verlorenen Authenticator nicht automatisch zurücksetzen. Bitten Sie die Administratoren Ihrer Abteilung, sich an den Resgrid-Support zu wenden, der Ihre Identität mit Ihrer Abteilung bestätigt, bevor sich etwas ändert. Folgen Sie bis dahin den Verfahren Ihrer Abteilung. + + + Authenticator ersetzen + + + Richten Sie Ihre neue Authenticator-App ein und geben Sie den angezeigten Code ein. Wählen Sie verlorene Passkeys aus; sie werden entfernt. + + + Als verloren zu entfernende Passkeys + + + Ersetzen und überall abmelden + + + Wiederherstellung abbrechen + + + Ihr Authenticator wurde ersetzt + + + Alle Sitzungen wurden abgemeldet. Speichern Sie Ihre neuen Wiederherstellungscodes und melden Sie sich dann mit Ihrem neuen Authenticator erneut an. + + + Anmelden + + + Diese Wiederherstellung ist nicht mehr gültig. Melden Sie sich an und beginnen Sie erneut. + + + Die Wiederherstellung ist vorübergehend nicht verfügbar. Melden Sie sich an und versuchen Sie es mit einem anderen Wiederherstellungscode. + + + Einer der zu entfernenden Passkeys gehört nicht Ihnen oder wurde bereits entfernt. + + + Rückkehr von der Anmeldung Ihrer Organisation… + + + Weiter + + + Gemeinsamer Arbeitsplatz + + + An einem gemeinsamen Arbeitsplatz wird jede Anmeldung in diesem Browser bei Inaktivität gesperrt, endet mit dem Schichtlimit und merkt sich den Browser nie. Jede Bedienperson meldet sich mit dem eigenen Konto an. + + + Diesen Browser als gemeinsamen Arbeitsplatz verwenden + + + Name des Arbeitsplatzes (optional) + + + Ein Name für den Support, z. B. „Leitstelle Platz 2“. Er bezeichnet den Arbeitsplatz, nicht eine Person. + + + Speichern + + + Dieser Browser ist jetzt ein gemeinsamer Arbeitsplatz. Das gilt ab der nächsten Anmeldung. + + + Dieser Browser ist kein gemeinsamer Arbeitsplatz mehr. Das gilt ab der nächsten Anmeldung. + + + Der Modus für gemeinsame Arbeitsplätze ist in dieser Resgrid-Installation nicht verfügbar. + + + Einstellungen für gemeinsamen Arbeitsplatz + + + Gemeinsamer Arbeitsplatz: Anmeldungen hier werden bei Inaktivität gesperrt und merken sich diesen Browser nie. + + + Gemeinsamer Arbeitsplatz ({0}): Anmeldungen hier werden bei Inaktivität gesperrt und merken sich diesen Browser nie. + + + Arbeitsplatz gesperrt + + + Dieser Arbeitsplatz wurde wegen Inaktivität gesperrt. + + + Dieser Arbeitsplatz ist gesperrt. + + + Angemeldet als {0} + + + Nur {0} kann ihn entsperren. Alle anderen: Bedienperson wechseln oder Schicht beenden. + + + Entsperren + + + Bedienperson wechseln + + + Schicht beenden + + + Sperren + + + Angemeldet bleiben + + + Sperrt in {0} + + + Dieser Arbeitsplatz wird in {0} Sekunden wegen Inaktivität gesperrt. + + + Die Schicht endet in {0} Minuten. + + + Schnelles Entsperren erfordert eine Authentifizierungs-App oder eine andere Bestätigungsmethode. Beenden Sie die Schicht und melden Sie sich erneut an. + + + Ihre Abteilung erfordert Single Sign-On. Beenden Sie die Schicht und melden Sie sich per SSO an. + + + Der Arbeitsplatz wurde erneut gesperrt. Entsperren Sie ihn noch einmal. + + + Diese Sitzung ist beendet. Beenden Sie die Schicht und melden Sie sich erneut an. + + + Zu viele Entsperrversuche. Warten Sie einige Minuten oder beenden Sie die Schicht und melden Sie sich erneut an. + + + Die Sitzung konnte nicht aktualisiert werden. Versuchen Sie es erneut. + + + Die Schicht an diesem gemeinsamen Arbeitsplatz ist beendet. Melden Sie sich erneut an. + + + Schicht beendet. Dieser Arbeitsplatz ist bereit für die nächste Anmeldung. + + + Abgemeldet. Die nächste Bedienperson kann sich jetzt anmelden. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx index bf43b9ce4..d7db2038f 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.el.resx @@ -48,6 +48,7 @@ Η απενεργοποίηση του 2FA θα μειώσει την ασφάλεια του λογαριασμού σας. Το κλειδί ελέγχου ταυτότητάς σας θα επαναφερθεί. Εισαγάγετε τον τρέχοντα κωδικό ελέγχου ταυτότητας για να επιβεβαιώσετε ότι θέλετε να απενεργοποιήσετε το 2FA: + Δεν μπορείτε να απενεργοποιήσετε το 2FA όσο έχετε κλειδιά πρόσβασης (passkeys). Αφαιρέστε πρώτα τα κλειδιά πρόσβασης και έπειτα απενεργοποιήστε το 2FA. Κωδικός Ελέγχου Ταυτότητας Απενεργοποίηση 2FA @@ -73,5 +74,410 @@ Χρήση εφαρμογής ελέγχου ταυτότητας αντ' αυτού Μη έγκυρος κωδικός ελέγχου ταυτότητας. Μη έγκυρος κωδικός ανάκτησης. + Replace Authenticator + Replace Your Authenticator App + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + Verify, Replace and Sign Out Everywhere + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + Sign In Again + + Κλειδιά πρόσβασης + + + Ένα κλειδί πρόσβασης σας επαληθεύει με το κλείδωμα οθόνης της συσκευής σας ή με ένα κλειδί ασφαλείας. Κάθε κλειδί πρόσβασης λειτουργεί μόνο στην εφαρμογή όπου προστέθηκε· προσθέστε ένα εδώ για τον ιστότοπο. Όταν αφαιρείτε ένα κλειδί πρόσβασης, παύει αμέσως να λειτουργεί στο Resgrid, αν και μπορεί να παραμείνει στη συσκευή ή στη διαχείριση κωδικών πρόσβασής σας. + + + Δεν έχετε ακόμη κλειδιά πρόσβασης. + + + Προσθήκη κλειδιού πρόσβασης για τον ιστότοπο + + + Η προσθήκη κλειδιών πρόσβασης για τον ιστότοπο δεν είναι ακόμη διαθέσιμη σε αυτή την εγκατάσταση. + + + Ρυθμίστε μια εφαρμογή ελέγχου ταυτότητας και κρατήστε κωδικούς ανάκτησης πριν προσθέσετε κλειδί πρόσβασης. + + + Όνομα + + + Λειτουργεί σε + + + Προστέθηκε + + + Τελευταία χρήση + + + Όχι ακόμη + + + Προστέθηκε σε κοινόχρηστη εγκατάσταση + + + Μετονομασία + + + Αφαίρεση + + + Νέο όνομα για αυτό το κλειδί πρόσβασης: + + + Να αφαιρεθεί αυτό το κλειδί πρόσβασης; Θα πάψει αμέσως να λειτουργεί στο Resgrid και οι συνδέσεις που έγιναν με αυτό θα τερματιστούν. + + + Το κλειδί πρόσβασης προστέθηκε. + + + Το κλειδί πρόσβασης αφαιρέθηκε. + + + Το παράθυρο του κλειδιού πρόσβασης έκλεισε. Δεν άλλαξε τίποτα. + + + Αυτό το πρόγραμμα περιήγησης δεν μπορεί να χρησιμοποιήσει κλειδιά πρόσβασης. Χρησιμοποιήστε την εφαρμογή ελέγχου ταυτότητας. + + + Δεν ήταν δυνατή η χρήση του κλειδιού πρόσβασης. Δοκιμάστε ξανά ή χρησιμοποιήστε την εφαρμογή ελέγχου ταυτότητας. + + + Έχετε ήδη τον μέγιστο επιτρεπόμενο αριθμό κλειδιών πρόσβασης για τον ιστότοπο. Αφαιρέστε πρώτα ένα. + + + Χρήση κλειδιού πρόσβασης + + + Ιστότοπος + + + Responder + + + Unit + + + Dispatch + + + IC + + + Το κλειδί πρόσβασης μετονομάστηκε. + + + Αυτή η συσκευή ή το κλειδί ασφαλείας έχει ήδη κλειδί πρόσβασης για τον λογαριασμό σας σε αυτή την εφαρμογή. + + + Ο κωδικός πρόσβασής σας έγινε δεκτός. Ολοκληρώστε τη σύνδεση με μία από τις μεθόδους επαλήθευσής σας. + + + Έγκριση με το Responder + + + Ανοίξτε το Responder στο τηλέφωνό σας και εγκρίνετε το αίτημα. Ελέγξτε ότι εμφανίζει αυτόν τον αριθμό: + + + Αναμονή για την έγκρισή σας στο Responder… + + + Το αίτημα απορρίφθηκε στο Responder. Χρησιμοποιήστε άλλη μέθοδο. + + + Το αίτημα έληξε πριν εγκριθεί. Ξεκινήστε ξανά. + + + Διακοπή αναμονής + + + Η έγκριση με το Responder δεν είναι διαθέσιμη αυτή τη στιγμή. Χρησιμοποιήστε άλλη μέθοδο. + + + Αυτό δεν λειτούργησε. Δοκιμάστε ξανά ή χρησιμοποιήστε άλλη μέθοδο. + + + Η σύνδεσή σας διήρκεσε πολύ. Συνδεθείτε ξανά. + + + Πάρα πολλές αποτυχημένες προσπάθειες. Περιμένετε λίγα λεπτά και συνδεθείτε ξανά. + + + Η πολιτική σύνδεσης του τμήματός σας άλλαξε. Συνδεθείτε ξανά. + + + Τα στοιχεία σύνδεσης του λογαριασμού σας άλλαξαν. Συνδεθείτε ξανά. + + + Η σύνδεση δεν είναι προσωρινά διαθέσιμη. Δοκιμάστε ξανά σε λίγο. + + + Αυτή η σύνδεση δεν ισχύει πλέον. Συνδεθείτε ξανά. + + + Αυτή η μέθοδος επαλήθευσης δεν είναι διαθέσιμη για αυτή τη σύνδεση. Χρησιμοποιήστε άλλη μέθοδο. + + + Έχει συμπληρωθεί ο μέγιστος αριθμός ενεργών συνεδριών του τμήματός σας. Τερματίστε μία από τις συνεδρίες σας ή επικοινωνήστε με τον διαχειριστή σας. + + + Συνδεθήκατε με κωδικό ανάκτησης. Αν δεν έχετε πλέον την εφαρμογή ελέγχου ταυτότητας, αντικαταστήστε την τώρα. + + + Χρήση άλλης μεθόδου + + + Σύνδεση με ενιαία σύνδεση (SSO) + + + Ενιαία σύνδεση (SSO) + + + Εισαγάγετε το όνομα χρήστη σας ή τον κωδικό του τμήματός σας. Θα συνδεθείτε με τον πάροχο ταυτότητας του οργανισμού σας. + + + Όνομα χρήστη + + + ή + + + Κωδικός τμήματος + + + Συνέχεια + + + Σύνδεση με κωδικό πρόσβασης + + + Εισαγάγετε το όνομα χρήστη σας ή τον κωδικό του τμήματός σας. + + + Η ενιαία σύνδεση δεν είναι διαθέσιμη για αυτή τη σύνδεση. + + + Δεν ήταν δυνατή η αντιστοίχιση αυτής της σύνδεσης με αυτό το πρόγραμμα περιήγησης. Ξεκινήστε ξανά. + + + Ο πάροχος ταυτότητάς σας δεν σας συνέδεσε. + + + Δεν ήταν δυνατή η ολοκλήρωση της ενιαίας σύνδεσης. Ξεκινήστε ξανά. + + + Η ενιαία σύνδεση διήρκεσε πολύ. Ξεκινήστε ξανά. + + + Αυτός ο λογαριασμός δεν μπορεί να συνδεθεί εδώ με ενιαία σύνδεση. + + + Ο πάροχος ταυτότητας συνέδεσε διαφορετικό λογαριασμό. Ξεκινήστε ξανά με τον λογαριασμό που χρησιμοποιείτε εδώ. + + + Ο πάροχος ταυτότητας δεν επιβεβαίωσε νέα σύνδεση. Ξεκινήστε ξανά. + + + Ο πάροχος ταυτότητας δεν επιβεβαίωσε έλεγχο ταυτότητας πολλαπλών παραγόντων. Χρησιμοποιήστε άλλη μέθοδο. + + + Το τμήμα σας απαιτεί έλεγχο ταυτότητας πολλαπλών παραγόντων και αυτός ο λογαριασμός δεν έχει ακόμη. Ρωτήστε τον διαχειριστή σας πώς να τον ρυθμίσετε. + + + Επαλήθευση με τον πάροχο ταυτότητας + + + Επιβεβαίωση με ενιαία σύνδεση + + + Η δοκιμή της αντιστοίχισης απαιτεί πρόσφατη επαλήθευση με την εφαρμογή ελέγχου ταυτότητας ή ένα κλειδί πρόσβασης. + + + Η δοκιμαστική σύνδεση δεν έφερε τιμή που η αντιστοίχιση μετρά ως MFA. Ελέγξτε την αντιστοίχιση και τον πάροχο ταυτότητας και δοκιμάστε ξανά. + + + Η αντιστοίχιση άλλαξε κατά τη δοκιμή. Δοκιμάστε την ξανά. + + + Η αντιστοίχιση πέρασε τη δοκιμή και ισχύει πλέον. + + + Ρύθμιση της εφαρμογής ελέγχου ταυτότητας + + + Το τμήμα σας απαιτεί δεύτερο παράγοντα και αυτός ο λογαριασμός δεν έχει ακόμη. Ρυθμίστε μια εφαρμογή ελέγχου ταυτότητας για να ολοκληρώσετε τη σύνδεση. + + + Επαλήθευση και σύνδεση + + + Ο κωδικός ρύθμισης έληξε. Εμφανίζεται νέος· σαρώστε τον ξανά. + + + Επιστροφή στη σύνδεση + + + Έχασα την εφαρμογή ελέγχου ταυτότητας + + + Χάσατε την εφαρμογή ελέγχου ταυτότητας; + + + Αν αποθηκεύσατε τους κωδικούς ανάκτησης, εισαγάγετε έναν για να ρυθμίσετε νέα εφαρμογή ελέγχου ταυτότητας και να καταργήσετε κλειδιά πρόσβασης που χάσατε. Όλες οι συνεδρίες αποσυνδέονται και μετά συνδέεστε ξανά. + + + Αντικατάσταση της εφαρμογής ελέγχου ταυτότητας + + + Για να χρησιμοποιήσετε κωδικό ανάκτησης, συνδεθείτε πρώτα με τον κωδικό πρόσβασης ή την ενιαία σύνδεση και επιλέξτε «Έχασα την εφαρμογή ελέγχου ταυτότητας». + + + Δεν έχετε κωδικό ανάκτησης; + + + Το Resgrid δεν μπορεί να επαναφέρει αυτόματα μια χαμένη εφαρμογή ελέγχου ταυτότητας. Ζητήστε από τους διαχειριστές του τμήματός σας να επικοινωνήσουν με την υποστήριξη του Resgrid, η οποία επιβεβαιώνει την ταυτότητά σας με το τμήμα σας πριν αλλάξει οτιδήποτε. Μέχρι τότε ακολουθείτε τις διαδικασίες του τμήματός σας. + + + Αντικατάσταση της εφαρμογής ελέγχου ταυτότητας + + + Ρυθμίστε τη νέα εφαρμογή ελέγχου ταυτότητας και εισαγάγετε τον κωδικό που εμφανίζει. Επιλέξτε όσα κλειδιά πρόσβασης χάσατε· θα καταργηθούν. + + + Κλειδιά πρόσβασης προς κατάργηση ως χαμένα + + + Αντικατάσταση και αποσύνδεση παντού + + + Ακύρωση ανάκτησης + + + Η εφαρμογή ελέγχου ταυτότητας αντικαταστάθηκε + + + Όλες οι συνεδρίες αποσυνδέθηκαν. Αποθηκεύστε τους νέους κωδικούς ανάκτησης και συνδεθείτε ξανά με τη νέα εφαρμογή ελέγχου ταυτότητας. + + + Σύνδεση + + + Αυτή η ανάκτηση δεν ισχύει πλέον. Συνδεθείτε και ξεκινήστε ξανά. + + + Η ανάκτηση δεν είναι προσωρινά διαθέσιμη. Συνδεθείτε και δοκιμάστε ξανά με άλλον κωδικό ανάκτησης. + + + Ένα από τα κλειδιά πρόσβασης προς κατάργηση δεν είναι δικό σας ή έχει ήδη καταργηθεί. + + + Επιστροφή από τη σύνδεση του οργανισμού σας… + + + Συνέχεια + + + Κοινόχρηστος σταθμός εργασίας + + + Σε κοινόχρηστο σταθμό εργασίας, κάθε σύνδεση σε αυτό το πρόγραμμα περιήγησης κλειδώνει όταν είναι αδρανής, λήγει στο όριο της βάρδιας και δεν απομνημονεύει ποτέ το πρόγραμμα περιήγησης. Κάθε χειριστής συνδέεται με τον δικό του λογαριασμό. + + + Χρήση αυτού του προγράμματος περιήγησης ως κοινόχρηστου σταθμού εργασίας + + + Όνομα σταθμού εργασίας (προαιρετικό) + + + Ένα όνομα για την υποστήριξη, όπως «Γραφείο διανομής 2». Προσδιορίζει τον σταθμό, όχι ένα άτομο. + + + Αποθήκευση + + + Αυτό το πρόγραμμα περιήγησης είναι πλέον κοινόχρηστος σταθμός εργασίας. Ισχύει από την επόμενη σύνδεση. + + + Αυτό το πρόγραμμα περιήγησης δεν είναι πλέον κοινόχρηστος σταθμός εργασίας. Ισχύει από την επόμενη σύνδεση. + + + Η λειτουργία κοινόχρηστου σταθμού εργασίας δεν είναι διαθέσιμη σε αυτήν την εγκατάσταση Resgrid. + + + Ρυθμίσεις κοινόχρηστου σταθμού εργασίας + + + Κοινόχρηστος σταθμός εργασίας: οι συνδέσεις εδώ κλειδώνουν όταν είναι αδρανείς και δεν απομνημονεύουν ποτέ αυτό το πρόγραμμα περιήγησης. + + + Κοινόχρηστος σταθμός εργασίας ({0}): οι συνδέσεις εδώ κλειδώνουν όταν είναι αδρανείς και δεν απομνημονεύουν ποτέ αυτό το πρόγραμμα περιήγησης. + + + Ο σταθμός εργασίας κλειδώθηκε + + + Αυτός ο σταθμός εργασίας κλειδώθηκε λόγω αδράνειας. + + + Αυτός ο σταθμός εργασίας είναι κλειδωμένος. + + + Συνδεδεμένος ως {0} + + + Μόνο ο/η {0} μπορεί να τον ξεκλειδώσει. Οποιοσδήποτε άλλος: αλλαγή χειριστή ή λήξη βάρδιας. + + + Ξεκλείδωμα + + + Αλλαγή χειριστή + + + Λήξη βάρδιας + + + Κλείδωμα + + + Παραμονή σε σύνδεση + + + Κλειδώνει σε {0} + + + Αυτός ο σταθμός εργασίας κλειδώνει σε {0} δευτερόλεπτα λόγω αδράνειας. + + + Η βάρδια λήγει σε {0} λεπτά. + + + Το γρήγορο ξεκλείδωμα απαιτεί εφαρμογή ελέγχου ταυτότητας ή άλλη μέθοδο επαλήθευσης. Λήξτε τη βάρδια και συνδεθείτε ξανά. + + + Το τμήμα σας απαιτεί ενιαία σύνδεση. Λήξτε τη βάρδια και συνδεθείτε με SSO. + + + Ο σταθμός εργασίας κλειδώθηκε ξανά. Ξεκλειδώστε τον ξανά. + + + Αυτή η συνεδρία έχει λήξει. Λήξτε τη βάρδια και συνδεθείτε ξανά. + + + Πάρα πολλές προσπάθειες ξεκλειδώματος. Περιμένετε λίγα λεπτά ή λήξτε τη βάρδια και συνδεθείτε ξανά. + + + Δεν ήταν δυνατή η ενημέρωση της συνεδρίας. Δοκιμάστε ξανά. + + + Η βάρδια σε αυτόν τον κοινόχρηστο σταθμό εργασίας έληξε. Συνδεθείτε ξανά. + + + Η βάρδια έληξε. Αυτός ο σταθμός εργασίας είναι έτοιμος για την επόμενη σύνδεση. + + + Αποσυνδεθήκατε. Ο επόμενος χειριστής μπορεί να συνδεθεί τώρα. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx index 3c975305f..dffeaf866 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.en.resx @@ -48,6 +48,7 @@ Disabling 2FA will reduce the security of your account. Your authenticator key will be reset. Enter your current authenticator code to confirm you wish to disable 2FA: + You can't turn off 2FA while you have passkeys. Remove your passkeys first, then turn off 2FA. Authenticator Code Disable 2FA @@ -73,5 +74,410 @@ Use authenticator app instead Invalid authenticator code. Invalid recovery code. + Replace Authenticator + Replace Your Authenticator App + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + Verify, Replace and Sign Out Everywhere + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + Sign In Again + + Passkeys + + + A passkey verifies you with your device's screen lock or a security key. Each passkey works only in the app it was added in; add one here for the web. Removing a passkey stops it working at Resgrid at once, although it may stay on the device or in your password manager. + + + You have no passkeys yet. + + + Add a passkey for the web + + + Adding passkeys for the web is not available on this deployment yet. + + + Set up an authenticator app, and keep recovery codes, before adding a passkey. + + + Name + + + Works in + + + Added + + + Last used + + + Not yet + + + Added on a shared installation + + + Rename + + + Remove + + + New name for this passkey: + + + Remove this passkey? It stops working at Resgrid at once, and sessions that signed in with it end. + + + Passkey added. + + + Passkey removed. + + + The passkey prompt was closed. Nothing was changed. + + + This browser cannot use passkeys. Use your authenticator app instead. + + + The passkey could not be used. Try again, or use your authenticator app. + + + You already have the most passkeys allowed for the web. Remove one first. + + + Use a passkey + + + Web + + + Responder + + + Unit + + + Dispatch + + + IC + + + Passkey renamed. + + + This device or security key already holds a passkey for your account in this app. + + + Your password was accepted. Finish signing in with one of your verification methods. + + + Approve with Responder + + + Open Responder on your phone and approve the request. Check that it shows this number: + + + Waiting for your approval in Responder… + + + The request was denied in Responder. Use another method. + + + The request ended before it was approved. Start again. + + + Stop waiting + + + Approval with Responder is not available right now. Use another method. + + + That did not work. Try again, or use another method. + + + Your sign-in took too long. Sign in again. + + + Too many failed attempts. Wait a few minutes and sign in again. + + + Your department's sign-in policy changed. Sign in again. + + + Your account's sign-in details changed. Sign in again. + + + Sign-in is temporarily unavailable. Try again in a moment. + + + This sign-in is no longer valid. Sign in again. + + + That verification method is not available for this sign-in. Use another method. + + + Your department's maximum number of active sessions has been reached. End one of your sessions or contact your administrator. + + + You signed in with a recovery code. If you no longer have your authenticator app, replace it now. + + + Use another method + + + Sign in with single sign-on + + + Single sign-on + + + Enter your username or your department's code. You will sign in with your organization's identity provider. + + + Username + + + or + + + Department code + + + Continue + + + Sign in with a password instead + + + Enter your username or your department's code. + + + Single sign-on is not available for this sign-in. + + + That sign-in could not be matched to this browser. Start again. + + + Your identity provider did not sign you in. + + + Single sign-on could not be completed. Start again. + + + Single sign-on took too long. Start again. + + + This account cannot sign in here with single sign-on. + + + Your identity provider signed in a different account. Start again with the account you are using here. + + + Your identity provider did not confirm a fresh sign-in. Start again. + + + Your identity provider did not confirm multi-factor authentication. Use another method. + + + Your department requires multi-factor authentication, and this account has none yet. Ask your administrator how to set it up. + + + Verify with your identity provider + + + Confirm with single sign-on + + + Testing the mapping needs a recent verification with your authenticator app or a passkey. + + + The test sign-in did not carry a value the mapping counts as MFA. Check the mapping and your identity provider, then test again. + + + The mapping changed during the test. Test it again. + + + The mapping passed its test and is now in effect. + + + Set up your authenticator app + + + Your department requires a second factor, and this account does not have one yet. Set up an authenticator app to finish signing in. + + + Verify and sign in + + + The setup code expired. A new one is shown; scan it again. + + + Back to sign-in + + + I lost my authenticator + + + Lost your authenticator? + + + If you saved your recovery codes, enter one to set up a new authenticator app and remove passkeys you have lost. Every session signs out, and you then sign in again. + + + Replace my authenticator + + + To use a recovery code, sign in with your password or single sign-on first, then choose "I lost my authenticator". + + + No recovery code? + + + Resgrid cannot reset a lost authenticator automatically. Ask your department's administrators to contact Resgrid support, who confirm who you are with your department before anything changes. Keep following your department's own procedures until then. + + + Replace your authenticator + + + Set up your new authenticator app and enter the code it shows. Choose any passkeys you have lost; they will be removed. + + + Passkeys to remove as lost + + + Replace and sign out everywhere + + + Cancel recovery + + + Your authenticator was replaced + + + Every session has signed out. Save your new recovery codes, then sign in again with your new authenticator. + + + Sign in + + + This recovery is no longer valid. Sign in and start again. + + + Recovery is temporarily unavailable. Sign in and try again with another recovery code. + + + One of the passkeys to remove is not yours or is already removed. + + + Returning from your organization's sign-in… + + + Continue + + + Shared workstation + + + On a shared workstation, every sign-in in this browser locks when idle, ends at the shift limit, and never remembers the browser. Each operator signs in with their own account. + + + Use this browser as a shared workstation + + + Workstation name (optional) + + + A name for support, such as "Dispatch desk 2". It identifies the station, not a person. + + + Save + + + This browser is now a shared workstation. It applies from the next sign-in. + + + This browser is no longer a shared workstation. It applies from the next sign-in. + + + Shared workstation mode is not available on this Resgrid deployment. + + + Shared workstation settings + + + Shared workstation: sign-ins here lock when idle and never remember this browser. + + + Shared workstation ({0}): sign-ins here lock when idle and never remember this browser. + + + Workstation locked + + + This workstation locked because it was idle. + + + This workstation is locked. + + + Signed in as {0} + + + Only {0} can unlock it. Anyone else: switch operator or end the shift. + + + Unlock + + + Switch operator + + + End shift + + + Lock + + + Stay signed in + + + Locks in {0} + + + This workstation locks in {0} seconds because it is idle. + + + The shift ends in {0} minutes. + + + Quick unlock needs an authenticator app or another verification method. End the shift and sign in again. + + + Your department requires single sign-on. End the shift and sign in with SSO. + + + The workstation locked again. Unlock it again. + + + This session has ended. End the shift and sign in again. + + + Too many unlock attempts. Wait a few minutes, or end the shift and sign in again. + + + The session could not be updated. Try again. + + + The shift on this shared workstation ended. Sign in again. + + + Shift ended. This workstation is ready for the next sign-in. + + + Signed out. The next operator can sign in now. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx index ce643ea7f..06bcf69c6 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.es.resx @@ -37,6 +37,7 @@ Listo Deshabilitar 2FA reducirá la seguridad de su cuenta. Se restablecerá su clave autenticadora. Ingrese su código autenticador actual para confirmar que desea deshabilitar 2FA: + No puede deshabilitar 2FA mientras tenga llaves de acceso. Elimine primero sus llaves de acceso y después deshabilite 2FA. Código Autenticador Deshabilitar 2FA Confirme Su Identidad @@ -54,5 +55,416 @@ Usar aplicación autenticadora en su lugar Código autenticador no válido. Código de recuperación no válido. + Replace Authenticator + Replace Your Authenticator App + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + Verify, Replace and Sign Out Everywhere + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + Sign In Again + + Claves de acceso + + + Una clave de acceso lo verifica con el bloqueo de pantalla de su dispositivo o con una llave de seguridad. Cada clave de acceso solo funciona en la aplicación donde se añadió; añada una aquí para la web. Al quitar una clave de acceso, deja de funcionar en Resgrid de inmediato, aunque puede permanecer en el dispositivo o en su administrador de contraseñas. + + + Todavía no tiene claves de acceso. + + + Añadir una clave de acceso para la web + + + Añadir claves de acceso para la web aún no está disponible en esta instalación. + + + Configure una aplicación de autenticación y conserve códigos de recuperación antes de añadir una clave de acceso. + + + Nombre + + + Funciona en + + + Añadida + + + Último uso + + + Todavía no + + + Añadida en una instalación compartida + + + Cambiar nombre + + + Quitar + + + Nuevo nombre para esta clave de acceso: + + + ¿Quitar esta clave de acceso? Dejará de funcionar en Resgrid de inmediato y se cerrarán las sesiones iniciadas con ella. + + + Clave de acceso añadida. + + + Clave de acceso quitada. + + + Se cerró la solicitud de la clave de acceso. No se cambió nada. + + + Este navegador no puede usar claves de acceso. Use su aplicación de autenticación. + + + No se pudo usar la clave de acceso. Inténtelo de nuevo o use su aplicación de autenticación. + + + Ya tiene el máximo de claves de acceso permitidas para la web. Quite una primero. + + + Usar una clave de acceso + + + Web + + + Responder + + + Unit + + + Dispatch + + + IC + + + Se cambió el nombre de la clave de acceso. + + + Este dispositivo o llave de seguridad ya tiene una clave de acceso para su cuenta en esta aplicación. + + + Su contraseña fue aceptada. Termine de iniciar sesión con uno de sus métodos de verificación. + + + Aprobar con Responder + + + Abra Responder en su teléfono y apruebe la solicitud. Compruebe que muestra este número: + + + Esperando su aprobación en Responder… + + + La solicitud se rechazó en Responder. Use otro método. + + + La solicitud terminó antes de aprobarse. Empiece de nuevo. + + + Dejar de esperar + + + La aprobación con Responder no está disponible ahora. Use otro método. + + + No funcionó. Vuelva a intentarlo o use otro método. + + + El inicio de sesión tardó demasiado. Vuelva a iniciar sesión. + + + Demasiados intentos fallidos. Espere unos minutos y vuelva a iniciar sesión. + + + La política de inicio de sesión de su departamento cambió. Vuelva a iniciar sesión. + + + Los datos de inicio de sesión de su cuenta cambiaron. Vuelva a iniciar sesión. + + + El inicio de sesión no está disponible temporalmente. Vuelva a intentarlo en un momento. + + + Este inicio de sesión ya no es válido. Vuelva a iniciar sesión. + + + Ese método de verificación no está disponible para este inicio de sesión. Use otro método. + + + Se alcanzó el número máximo de sesiones activas de su departamento. Cierre una de sus sesiones o comuníquese con su administrador. + + + Inició sesión con un código de recuperación. Si ya no tiene su aplicación de autenticación, reemplácela ahora. + + + Usar otro método + + + Iniciar sesión con inicio de sesión único + + + Inicio de sesión único + + + Introduzca su nombre de usuario o el código de su departamento. Iniciará sesión con el proveedor de identidad de su organización. + + + Nombre de usuario + + + o + + + Código del departamento + + + Continuar + + + Iniciar sesión con una contraseña + + + Introduzca su nombre de usuario o el código de su departamento. + + + El inicio de sesión único no está disponible para este inicio de sesión. + + + No se pudo asociar ese inicio de sesión con este navegador. Empiece de nuevo. + + + Su proveedor de identidad no inició su sesión. + + + No se pudo completar el inicio de sesión único. Empiece de nuevo. + + + El inicio de sesión único tardó demasiado. Empiece de nuevo. + + + Esta cuenta no puede iniciar sesión aquí con inicio de sesión único. + + + Su proveedor de identidad inició sesión con otra cuenta. Empiece de nuevo con la cuenta que usa aquí. + + + Su proveedor de identidad no confirmó un inicio de sesión reciente. Empiece de nuevo. + + + Su proveedor de identidad no confirmó la autenticación multifactor. Use otro método. + + + Su departamento exige autenticación multifactor y esta cuenta aún no la tiene. Pregunte a su administrador cómo configurarla. + + + Verificar con su proveedor de identidad + + + Confirmar con inicio de sesión único + + + Probar la asignación requiere una verificación reciente con su aplicación de autenticación o una clave de acceso. + + + El inicio de sesión de prueba no incluía un valor que la asignación cuente como MFA. Revise la asignación y su proveedor de identidad y vuelva a probar. + + + La asignación cambió durante la prueba. Vuelva a probarla. + + + La asignación superó su prueba y ya está en vigor. + + + Configure su aplicación de autenticación + + + Su departamento exige un segundo factor y esta cuenta aún no lo tiene. Configure una aplicación de autenticación para terminar de iniciar sesión. + + + Verificar e iniciar sesión + + + El código de configuración caducó. Se muestra uno nuevo; escanéelo otra vez. + + + Volver al inicio de sesión + + + Perdí mi autenticador + + + ¿Perdió su autenticador? + + + Si guardó sus códigos de recuperación, introduzca uno para configurar una nueva aplicación de autenticación y quitar las claves de acceso que haya perdido. Se cerrarán todas las sesiones y después volverá a iniciar sesión. + + + Reemplazar mi autenticador + + + Para usar un código de recuperación, inicie sesión primero con su contraseña o inicio de sesión único y luego elija «Perdí mi autenticador». + + + ¿No tiene código de recuperación? + + + Resgrid no puede restablecer automáticamente un autenticador perdido. Pida a los administradores de su departamento que contacten con el soporte de Resgrid, que confirmará su identidad con su departamento antes de cambiar nada. Mientras tanto, siga los procedimientos de su departamento. + + + Reemplace su autenticador + + + Configure su nueva aplicación de autenticación e introduzca el código que muestra. Elija las claves de acceso que haya perdido; se quitarán. + + + Claves de acceso que se quitarán por pérdida + + + Reemplazar y cerrar sesión en todas partes + + + Cancelar la recuperación + + + Se reemplazó su autenticador + + + Se cerraron todas las sesiones. Guarde sus nuevos códigos de recuperación y vuelva a iniciar sesión con su nuevo autenticador. + + + Iniciar sesión + + + Esta recuperación ya no es válida. Inicie sesión y empiece de nuevo. + + + La recuperación no está disponible temporalmente. Inicie sesión e inténtelo de nuevo con otro código de recuperación. + + + Una de las claves de acceso que se quitarán no es suya o ya se quitó. + + + Volviendo del inicio de sesión de su organización… + + + Continuar + + + Estación de trabajo compartida + + + En una estación de trabajo compartida, cada inicio de sesión en este navegador se bloquea por inactividad, termina al límite del turno y nunca recuerda el navegador. Cada operador inicia sesión con su propia cuenta. + + + Usar este navegador como estación de trabajo compartida + + + Nombre de la estación (opcional) + + + Un nombre para soporte, como "Puesto de despacho 2". Identifica la estación, no a una persona. + + + Guardar + + + Este navegador es ahora una estación de trabajo compartida. Se aplica desde el próximo inicio de sesión. + + + Este navegador ya no es una estación de trabajo compartida. Se aplica desde el próximo inicio de sesión. + + + El modo de estación de trabajo compartida no está disponible en esta instalación de Resgrid. + + + Configuración de estación compartida + + + Estación de trabajo compartida: los inicios de sesión aquí se bloquean por inactividad y nunca recuerdan este navegador. + + + Estación de trabajo compartida ({0}): los inicios de sesión aquí se bloquean por inactividad y nunca recuerdan este navegador. + + + Estación bloqueada + + + Esta estación se bloqueó por inactividad. + + + Esta estación está bloqueada. + + + Sesión iniciada como {0} + + + Solo {0} puede desbloquearla. Cualquier otra persona: cambie de operador o finalice el turno. + + + Desbloquear + + + Cambiar de operador + + + Finalizar turno + + + Bloquear + + + Mantener la sesión + + + Se bloquea en {0} + + + Esta estación se bloqueará en {0} segundos por inactividad. + + + El turno termina en {0} minutos. + + + El desbloqueo rápido necesita una aplicación de autenticación u otro método de verificación. Finalice el turno e inicie sesión de nuevo. + + + Su departamento requiere inicio de sesión único. Finalice el turno e inicie sesión con SSO. + + + La estación se bloqueó de nuevo. Desbloquéela otra vez. + + + Esta sesión ha terminado. Finalice el turno e inicie sesión de nuevo. + + + Demasiados intentos de desbloqueo. Espere unos minutos o finalice el turno e inicie sesión de nuevo. + + + No se pudo actualizar la sesión. Inténtelo de nuevo. + + + El turno en esta estación de trabajo compartida terminó. Inicie sesión de nuevo. + + + Turno finalizado. Esta estación está lista para el próximo inicio de sesión. + + + Sesión cerrada. El siguiente operador puede iniciar sesión ahora. + + + Código de autenticación + + + xxxx-xxxx-xxxx + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx index ed49a16cc..e85405820 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.fr.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Vous ne pouvez pas désactiver la 2FA tant que vous avez des clés d'accès. Supprimez d'abord vos clés d'accès, puis désactivez la 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Clés d'accès + + + Une clé d'accès vous vérifie avec le verrouillage d'écran de votre appareil ou une clé de sécurité. Chaque clé d'accès ne fonctionne que dans l'application où elle a été ajoutée ; ajoutez-en une ici pour le web. Une clé d'accès supprimée cesse aussitôt de fonctionner sur Resgrid, même si elle peut rester sur l'appareil ou dans votre gestionnaire de mots de passe. + + + Vous n'avez encore aucune clé d'accès. + + + Ajouter une clé d'accès pour le web + + + L'ajout de clés d'accès pour le web n'est pas encore disponible sur cette installation. + + + Configurez une application d'authentification et conservez des codes de récupération avant d'ajouter une clé d'accès. + + + Nom + + + Fonctionne dans + + + Ajoutée + + + Dernière utilisation + + + Pas encore + + + Ajoutée sur une installation partagée + + + Renommer + + + Supprimer + + + Nouveau nom pour cette clé d'accès : + + + Supprimer cette clé d'accès ? Elle cesse aussitôt de fonctionner sur Resgrid, et les sessions ouvertes avec elle sont fermées. + + + Clé d'accès ajoutée. + + + Clé d'accès supprimée. + + + La fenêtre de la clé d'accès a été fermée. Rien n'a été modifié. + + + Ce navigateur ne peut pas utiliser de clés d'accès. Utilisez plutôt votre application d'authentification. + + + La clé d'accès n'a pas pu être utilisée. Réessayez ou utilisez votre application d'authentification. + + + Vous avez déjà le nombre maximal de clés d'accès autorisé pour le web. Supprimez-en une d'abord. + + + Utiliser une clé d'accès + + + Web + + + Responder + + + Unit + + + Dispatch + + + IC + + + Clé d'accès renommée. + + + Cet appareil ou cette clé de sécurité contient déjà une clé d'accès pour votre compte dans cette application. + + + Votre mot de passe a été accepté. Terminez la connexion avec l'une de vos méthodes de vérification. + + + Approuver avec Responder + + + Ouvrez Responder sur votre téléphone et approuvez la demande. Vérifiez qu'il affiche ce numéro : + + + En attente de votre approbation dans Responder… + + + La demande a été refusée dans Responder. Utilisez une autre méthode. + + + La demande a pris fin avant d'être approuvée. Recommencez. + + + Ne plus attendre + + + L'approbation avec Responder n'est pas disponible pour le moment. Utilisez une autre méthode. + + + Cela n'a pas fonctionné. Réessayez ou utilisez une autre méthode. + + + Votre connexion a pris trop de temps. Reconnectez-vous. + + + Trop de tentatives échouées. Patientez quelques minutes, puis reconnectez-vous. + + + La politique de connexion de votre département a changé. Reconnectez-vous. + + + Les informations de connexion de votre compte ont changé. Reconnectez-vous. + + + La connexion est momentanément indisponible. Réessayez dans un instant. + + + Cette connexion n'est plus valide. Reconnectez-vous. + + + Cette méthode de vérification n'est pas disponible pour cette connexion. Utilisez une autre méthode. + + + Le nombre maximal de sessions actives de votre département est atteint. Fermez l'une de vos sessions ou contactez votre administrateur. + + + Vous vous êtes connecté avec un code de récupération. Si vous n'avez plus votre application d'authentification, remplacez-la maintenant. + + + Utiliser une autre méthode + + + Se connecter avec l'authentification unique + + + Authentification unique + + + Saisissez votre nom d'utilisateur ou le code de votre département. Vous vous connecterez avec le fournisseur d'identité de votre organisation. + + + Nom d'utilisateur + + + ou + + + Code du département + + + Continuer + + + Se connecter plutôt avec un mot de passe + + + Saisissez votre nom d'utilisateur ou le code de votre département. + + + L'authentification unique n'est pas disponible pour cette connexion. + + + Cette connexion n'a pas pu être associée à ce navigateur. Recommencez. + + + Votre fournisseur d'identité ne vous a pas connecté. + + + L'authentification unique n'a pas pu aboutir. Recommencez. + + + L'authentification unique a pris trop de temps. Recommencez. + + + Ce compte ne peut pas se connecter ici avec l'authentification unique. + + + Votre fournisseur d'identité a connecté un autre compte. Recommencez avec le compte que vous utilisez ici. + + + Votre fournisseur d'identité n'a pas confirmé une nouvelle connexion. Recommencez. + + + Votre fournisseur d'identité n'a pas confirmé l'authentification multifacteur. Utilisez une autre méthode. + + + Votre département exige l'authentification multifacteur, et ce compte n'en a pas encore. Demandez à votre administrateur comment la configurer. + + + Vérifier avec votre fournisseur d'identité + + + Confirmer avec l'authentification unique + + + Tester la correspondance nécessite une vérification récente avec votre application d'authentification ou une clé d'accès. + + + La connexion de test ne portait aucune valeur que la correspondance compte comme MFA. Vérifiez la correspondance et votre fournisseur d'identité, puis testez à nouveau. + + + La correspondance a changé pendant le test. Testez-la à nouveau. + + + La correspondance a réussi son test et est désormais en vigueur. + + + Configurez votre application d'authentification + + + Votre département exige un second facteur, et ce compte n'en a pas encore. Configurez une application d'authentification pour terminer la connexion. + + + Vérifier et se connecter + + + Le code de configuration a expiré. Un nouveau s'affiche ; scannez-le à nouveau. + + + Retour à la connexion + + + J'ai perdu mon authentificateur + + + Vous avez perdu votre authentificateur ? + + + Si vous avez conservé vos codes de récupération, saisissez-en un pour configurer une nouvelle application d'authentification et supprimer les clés d'accès perdues. Toutes les sessions sont fermées, puis vous vous reconnectez. + + + Remplacer mon authentificateur + + + Pour utiliser un code de récupération, connectez-vous d'abord avec votre mot de passe ou l'authentification unique, puis choisissez « J'ai perdu mon authentificateur ». + + + Pas de code de récupération ? + + + Resgrid ne peut pas réinitialiser automatiquement un authentificateur perdu. Demandez aux administrateurs de votre département de contacter l'assistance Resgrid, qui confirme votre identité auprès de votre département avant tout changement. D'ici là, suivez les procédures de votre département. + + + Remplacez votre authentificateur + + + Configurez votre nouvelle application d'authentification et saisissez le code affiché. Choisissez les clés d'accès perdues ; elles seront supprimées. + + + Clés d'accès à supprimer comme perdues + + + Remplacer et se déconnecter partout + + + Annuler la récupération + + + Votre authentificateur a été remplacé + + + Toutes les sessions ont été fermées. Enregistrez vos nouveaux codes de récupération, puis reconnectez-vous avec votre nouvel authentificateur. + + + Se connecter + + + Cette récupération n'est plus valide. Connectez-vous et recommencez. + + + La récupération est momentanément indisponible. Connectez-vous et réessayez avec un autre code de récupération. + + + L'une des clés d'accès à supprimer ne vous appartient pas ou a déjà été supprimée. + + + Retour depuis la connexion de votre organisation… + + + Continuer + + + Poste de travail partagé + + + Sur un poste de travail partagé, chaque connexion dans ce navigateur se verrouille en cas d’inactivité, se termine à la limite du quart et ne mémorise jamais le navigateur. Chaque opérateur se connecte avec son propre compte. + + + Utiliser ce navigateur comme poste de travail partagé + + + Nom du poste (facultatif) + + + Un nom pour le support, par exemple « Poste de régulation 2 ». Il identifie le poste, pas une personne. + + + Enregistrer + + + Ce navigateur est désormais un poste de travail partagé. Cela s’applique dès la prochaine connexion. + + + Ce navigateur n’est plus un poste de travail partagé. Cela s’applique dès la prochaine connexion. + + + Le mode poste de travail partagé n’est pas disponible sur ce déploiement Resgrid. + + + Paramètres du poste partagé + + + Poste de travail partagé : les connexions ici se verrouillent en cas d’inactivité et ne mémorisent jamais ce navigateur. + + + Poste de travail partagé ({0}) : les connexions ici se verrouillent en cas d’inactivité et ne mémorisent jamais ce navigateur. + + + Poste verrouillé + + + Ce poste s’est verrouillé pour cause d’inactivité. + + + Ce poste est verrouillé. + + + Connecté en tant que {0} + + + Seul {0} peut le déverrouiller. Toute autre personne : changez d’opérateur ou terminez le quart. + + + Déverrouiller + + + Changer d’opérateur + + + Terminer le quart + + + Verrouiller + + + Rester connecté + + + Verrouillage dans {0} + + + Ce poste se verrouille dans {0} secondes pour cause d’inactivité. + + + Le quart se termine dans {0} minutes. + + + Le déverrouillage rapide nécessite une application d’authentification ou une autre méthode de vérification. Terminez le quart et reconnectez-vous. + + + Votre service exige l’authentification unique. Terminez le quart et connectez-vous avec le SSO. + + + Le poste s’est de nouveau verrouillé. Déverrouillez-le à nouveau. + + + Cette session est terminée. Terminez le quart et reconnectez-vous. + + + Trop de tentatives de déverrouillage. Patientez quelques minutes ou terminez le quart et reconnectez-vous. + + + La session n’a pas pu être mise à jour. Réessayez. + + + Le quart sur ce poste de travail partagé est terminé. Reconnectez-vous. + + + Quart terminé. Ce poste est prêt pour la prochaine connexion. + + + Déconnecté. L’opérateur suivant peut se connecter maintenant. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx index ed49a16cc..0eb323ede 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.it.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Non puoi disattivare la 2FA finché hai delle passkey. Rimuovi prima le tue passkey, poi disattiva la 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Passkey + + + Una passkey ti verifica con il blocco schermo del dispositivo o con una chiave di sicurezza. Ogni passkey funziona solo nell'app in cui è stata aggiunta; aggiungine una qui per il web. Se rimuovi una passkey, smette subito di funzionare su Resgrid, anche se può restare sul dispositivo o nel tuo gestore di password. + + + Non hai ancora nessuna passkey. + + + Aggiungi una passkey per il web + + + L'aggiunta di passkey per il web non è ancora disponibile in questa installazione. + + + Configura un'app di autenticazione e conserva i codici di recupero prima di aggiungere una passkey. + + + Nome + + + Funziona in + + + Aggiunta + + + Ultimo utilizzo + + + Non ancora + + + Aggiunta su un'installazione condivisa + + + Rinomina + + + Rimuovi + + + Nuovo nome per questa passkey: + + + Rimuovere questa passkey? Smette subito di funzionare su Resgrid e le sessioni aperte con essa vengono chiuse. + + + Passkey aggiunta. + + + Passkey rimossa. + + + La richiesta della passkey è stata chiusa. Non è stato modificato nulla. + + + Questo browser non può usare le passkey. Usa invece la tua app di autenticazione. + + + Non è stato possibile usare la passkey. Riprova o usa la tua app di autenticazione. + + + Hai già il numero massimo di passkey consentite per il web. Rimuovine prima una. + + + Usa una passkey + + + Web + + + Responder + + + Unit + + + Dispatch + + + IC + + + Passkey rinominata. + + + Questo dispositivo o chiave di sicurezza ha già una passkey per il tuo account in questa app. + + + La password è stata accettata. Completa l'accesso con uno dei tuoi metodi di verifica. + + + Approva con Responder + + + Apri Responder sul telefono e approva la richiesta. Controlla che mostri questo numero: + + + In attesa della tua approvazione in Responder… + + + La richiesta è stata rifiutata in Responder. Usa un altro metodo. + + + La richiesta è terminata prima dell'approvazione. Ricomincia. + + + Smetti di aspettare + + + L'approvazione con Responder non è disponibile al momento. Usa un altro metodo. + + + Non ha funzionato. Riprova o usa un altro metodo. + + + L'accesso ha richiesto troppo tempo. Accedi di nuovo. + + + Troppi tentativi non riusciti. Attendi qualche minuto e accedi di nuovo. + + + La politica di accesso del tuo dipartimento è cambiata. Accedi di nuovo. + + + I dati di accesso del tuo account sono cambiati. Accedi di nuovo. + + + L'accesso non è temporaneamente disponibile. Riprova tra poco. + + + Questo accesso non è più valido. Accedi di nuovo. + + + Questo metodo di verifica non è disponibile per questo accesso. Usa un altro metodo. + + + Hai raggiunto il numero massimo di sessioni attive del tuo dipartimento. Chiudi una delle tue sessioni o contatta l'amministratore. + + + Hai eseguito l'accesso con un codice di recupero. Se non hai più la tua app di autenticazione, sostituiscila ora. + + + Usa un altro metodo + + + Accedi con il single sign-on + + + Single sign-on + + + Inserisci il tuo nome utente o il codice del tuo dipartimento. Accederai con il provider di identità della tua organizzazione. + + + Nome utente + + + oppure + + + Codice del dipartimento + + + Continua + + + Accedi invece con una password + + + Inserisci il tuo nome utente o il codice del tuo dipartimento. + + + Il single sign-on non è disponibile per questo accesso. + + + Non è stato possibile associare questo accesso al browser. Ricomincia. + + + Il tuo provider di identità non ti ha fatto accedere. + + + Non è stato possibile completare il single sign-on. Ricomincia. + + + Il single sign-on ha richiesto troppo tempo. Ricomincia. + + + Questo account non può accedere qui con il single sign-on. + + + Il provider di identità ha fatto accedere un altro account. Ricomincia con l'account che usi qui. + + + Il provider di identità non ha confermato un nuovo accesso. Ricomincia. + + + Il provider di identità non ha confermato l'autenticazione a più fattori. Usa un altro metodo. + + + Il tuo dipartimento richiede l'autenticazione a più fattori e questo account non ne ha ancora. Chiedi all'amministratore come configurarla. + + + Verifica con il tuo provider di identità + + + Conferma con il single sign-on + + + Per testare la mappatura serve una verifica recente con la tua app di autenticazione o una passkey. + + + L'accesso di prova non conteneva un valore che la mappatura conta come MFA. Controlla la mappatura e il provider di identità, poi riprova. + + + La mappatura è cambiata durante il test. Ripeti il test. + + + La mappatura ha superato il test ed è ora in vigore. + + + Configura la tua app di autenticazione + + + Il tuo dipartimento richiede un secondo fattore e questo account non ne ha ancora uno. Configura un'app di autenticazione per completare l'accesso. + + + Verifica e accedi + + + Il codice di configurazione è scaduto. Ne viene mostrato uno nuovo: scansionalo di nuovo. + + + Torna all'accesso + + + Ho perso il mio autenticatore + + + Hai perso il tuo autenticatore? + + + Se hai salvato i codici di recupero, inseriscine uno per configurare una nuova app di autenticazione e rimuovere le passkey perse. Tutte le sessioni vengono chiuse e poi accedi di nuovo. + + + Sostituisci il mio autenticatore + + + Per usare un codice di recupero, accedi prima con la password o il single sign-on, poi scegli «Ho perso il mio autenticatore». + + + Nessun codice di recupero? + + + Resgrid non può reimpostare automaticamente un autenticatore perso. Chiedi agli amministratori del tuo dipartimento di contattare l'assistenza Resgrid, che conferma la tua identità con il dipartimento prima di cambiare qualsiasi cosa. Nel frattempo segui le procedure del tuo dipartimento. + + + Sostituisci il tuo autenticatore + + + Configura la nuova app di autenticazione e inserisci il codice che mostra. Scegli le passkey che hai perso: verranno rimosse. + + + Passkey da rimuovere perché perse + + + Sostituisci ed esci ovunque + + + Annulla il recupero + + + Il tuo autenticatore è stato sostituito + + + Tutte le sessioni sono state chiuse. Salva i nuovi codici di recupero, poi accedi di nuovo con il nuovo autenticatore. + + + Accedi + + + Questo recupero non è più valido. Accedi e ricomincia. + + + Il recupero non è temporaneamente disponibile. Accedi e riprova con un altro codice di recupero. + + + Una delle passkey da rimuovere non è tua o è già stata rimossa. + + + Ritorno dall'accesso della tua organizzazione… + + + Continua + + + Postazione condivisa + + + Su una postazione condivisa, ogni accesso in questo browser si blocca per inattività, termina al limite del turno e non memorizza mai il browser. Ogni operatore accede con il proprio account. + + + Usa questo browser come postazione condivisa + + + Nome della postazione (facoltativo) + + + Un nome per l’assistenza, ad esempio "Sala operativa 2". Identifica la postazione, non una persona. + + + Salva + + + Questo browser è ora una postazione condivisa. Si applica dal prossimo accesso. + + + Questo browser non è più una postazione condivisa. Si applica dal prossimo accesso. + + + La modalità postazione condivisa non è disponibile in questa installazione di Resgrid. + + + Impostazioni postazione condivisa + + + Postazione condivisa: gli accessi qui si bloccano per inattività e non memorizzano mai questo browser. + + + Postazione condivisa ({0}): gli accessi qui si bloccano per inattività e non memorizzano mai questo browser. + + + Postazione bloccata + + + Questa postazione si è bloccata per inattività. + + + Questa postazione è bloccata. + + + Accesso effettuato come {0} + + + Solo {0} può sbloccarla. Chiunque altro: cambi operatore o termini il turno. + + + Sblocca + + + Cambia operatore + + + Termina turno + + + Blocca + + + Rimani connesso + + + Si blocca tra {0} + + + Questa postazione si bloccherà tra {0} secondi per inattività. + + + Il turno termina tra {0} minuti. + + + Lo sblocco rapido richiede un’app di autenticazione o un altro metodo di verifica. Termina il turno e accedi di nuovo. + + + Il tuo reparto richiede il single sign-on. Termina il turno e accedi con SSO. + + + La postazione si è bloccata di nuovo. Sbloccala di nuovo. + + + Questa sessione è terminata. Termina il turno e accedi di nuovo. + + + Troppi tentativi di sblocco. Attendi qualche minuto oppure termina il turno e accedi di nuovo. + + + Impossibile aggiornare la sessione. Riprova. + + + Il turno su questa postazione condivisa è terminato. Accedi di nuovo. + + + Turno terminato. Questa postazione è pronta per il prossimo accesso. + + + Disconnesso. Il prossimo operatore può accedere ora. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx index ed49a16cc..f9b5a95ce 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.pl.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Nie możesz wyłączyć 2FA, dopóki masz klucze dostępu. Najpierw usuń klucze dostępu, a potem wyłącz 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Klucze dostępu + + + Klucz dostępu potwierdza Twoją tożsamość blokadą ekranu urządzenia lub kluczem bezpieczeństwa. Każdy klucz dostępu działa tylko w aplikacji, w której go dodano; tutaj dodasz klucz dla wersji internetowej. Usunięty klucz dostępu od razu przestaje działać w Resgrid, choć może pozostać na urządzeniu lub w menedżerze haseł. + + + Nie masz jeszcze żadnych kluczy dostępu. + + + Dodaj klucz dostępu dla wersji internetowej + + + Dodawanie kluczy dostępu dla wersji internetowej nie jest jeszcze dostępne w tej instalacji. + + + Zanim dodasz klucz dostępu, skonfiguruj aplikację uwierzytelniającą i zachowaj kody odzyskiwania. + + + Nazwa + + + Działa w + + + Dodano + + + Ostatnie użycie + + + Jeszcze nie + + + Dodano na współdzielonej instalacji + + + Zmień nazwę + + + Usuń + + + Nowa nazwa tego klucza dostępu: + + + Usunąć ten klucz dostępu? Od razu przestanie działać w Resgrid, a sesje, w których użyto go do logowania, zostaną zakończone. + + + Dodano klucz dostępu. + + + Usunięto klucz dostępu. + + + Okno klucza dostępu zostało zamknięte. Niczego nie zmieniono. + + + Ta przeglądarka nie obsługuje kluczy dostępu. Użyj aplikacji uwierzytelniającej. + + + Nie udało się użyć klucza dostępu. Spróbuj ponownie lub użyj aplikacji uwierzytelniającej. + + + Masz już maksymalną liczbę kluczy dostępu dla wersji internetowej. Najpierw usuń jeden. + + + Użyj klucza dostępu + + + Wersja internetowa + + + Responder + + + Unit + + + Dispatch + + + IC + + + Zmieniono nazwę klucza dostępu. + + + To urządzenie lub klucz bezpieczeństwa ma już klucz dostępu do Twojego konta w tej aplikacji. + + + Hasło zostało przyjęte. Dokończ logowanie jedną ze swoich metod weryfikacji. + + + Zatwierdź w Responder + + + Otwórz Responder w telefonie i zatwierdź prośbę. Sprawdź, czy pokazuje ten numer: + + + Czekamy na zatwierdzenie w Responder… + + + Prośba została odrzucona w Responder. Użyj innej metody. + + + Prośba wygasła przed zatwierdzeniem. Zacznij od nowa. + + + Przestań czekać + + + Zatwierdzanie w Responder jest teraz niedostępne. Użyj innej metody. + + + To się nie udało. Spróbuj ponownie lub użyj innej metody. + + + Logowanie trwało zbyt długo. Zaloguj się ponownie. + + + Zbyt wiele nieudanych prób. Odczekaj kilka minut i zaloguj się ponownie. + + + Zasady logowania w Twoim departamencie się zmieniły. Zaloguj się ponownie. + + + Dane logowania Twojego konta się zmieniły. Zaloguj się ponownie. + + + Logowanie jest chwilowo niedostępne. Spróbuj ponownie za chwilę. + + + To logowanie jest już nieważne. Zaloguj się ponownie. + + + Ta metoda weryfikacji jest niedostępna dla tego logowania. Użyj innej metody. + + + Osiągnięto maksymalną liczbę aktywnych sesji w Twoim departamencie. Zakończ jedną ze swoich sesji lub skontaktuj się z administratorem. + + + Zalogowano się kodem odzyskiwania. Jeśli nie masz już aplikacji uwierzytelniającej, zastąp ją teraz. + + + Użyj innej metody + + + Zaloguj się przez logowanie jednokrotne + + + Logowanie jednokrotne + + + Wpisz swoją nazwę użytkownika lub kod departamentu. Zalogujesz się przez dostawcę tożsamości swojej organizacji. + + + Nazwa użytkownika + + + lub + + + Kod departamentu + + + Dalej + + + Zaloguj się hasłem + + + Wpisz nazwę użytkownika lub kod departamentu. + + + Logowanie jednokrotne jest niedostępne dla tego logowania. + + + Nie udało się powiązać tego logowania z tą przeglądarką. Zacznij od nowa. + + + Dostawca tożsamości Cię nie zalogował. + + + Nie udało się ukończyć logowania jednokrotnego. Zacznij od nowa. + + + Logowanie jednokrotne trwało zbyt długo. Zacznij od nowa. + + + To konto nie może się tu zalogować przez logowanie jednokrotne. + + + Dostawca tożsamości zalogował inne konto. Zacznij od nowa z kontem, którego tu używasz. + + + Dostawca tożsamości nie potwierdził nowego logowania. Zacznij od nowa. + + + Dostawca tożsamości nie potwierdził uwierzytelniania wieloskładnikowego. Użyj innej metody. + + + Twój departament wymaga uwierzytelniania wieloskładnikowego, a to konto jeszcze go nie ma. Zapytaj administratora, jak je skonfigurować. + + + Zweryfikuj u dostawcy tożsamości + + + Potwierdź przez logowanie jednokrotne + + + Test mapowania wymaga niedawnej weryfikacji aplikacją uwierzytelniającą lub kluczem dostępu. + + + Logowanie testowe nie zawierało wartości, którą mapowanie liczy jako MFA. Sprawdź mapowanie i dostawcę tożsamości, a potem przetestuj ponownie. + + + Mapowanie zmieniło się w trakcie testu. Przetestuj je ponownie. + + + Mapowanie przeszło test i teraz obowiązuje. + + + Skonfiguruj aplikację uwierzytelniającą + + + Twój departament wymaga drugiego składnika, a to konto jeszcze go nie ma. Skonfiguruj aplikację uwierzytelniającą, aby dokończyć logowanie. + + + Zweryfikuj i zaloguj się + + + Kod konfiguracji wygasł. Wyświetlono nowy; zeskanuj go ponownie. + + + Wróć do logowania + + + Zgubiłem uwierzytelniacz + + + Zgubiłeś uwierzytelniacz? + + + Jeśli zapisałeś kody odzyskiwania, wpisz jeden, aby skonfigurować nową aplikację uwierzytelniającą i usunąć utracone klucze dostępu. Wszystkie sesje zostaną wylogowane, a potem zalogujesz się ponownie. + + + Zastąp uwierzytelniacz + + + Aby użyć kodu odzyskiwania, najpierw zaloguj się hasłem lub przez logowanie jednokrotne, a potem wybierz „Zgubiłem uwierzytelniacz”. + + + Nie masz kodu odzyskiwania? + + + Resgrid nie może automatycznie zresetować utraconego uwierzytelniacza. Poproś administratorów departamentu o kontakt z pomocą techniczną Resgrid, która przed jakąkolwiek zmianą potwierdzi Twoją tożsamość z departamentem. Do tego czasu postępuj zgodnie z procedurami departamentu. + + + Zastąp uwierzytelniacz + + + Skonfiguruj nową aplikację uwierzytelniającą i wpisz wyświetlony kod. Wybierz utracone klucze dostępu; zostaną usunięte. + + + Klucze dostępu do usunięcia jako utracone + + + Zastąp i wyloguj wszędzie + + + Anuluj odzyskiwanie + + + Uwierzytelniacz został zastąpiony + + + Wszystkie sesje zostały wylogowane. Zapisz nowe kody odzyskiwania, a potem zaloguj się ponownie nowym uwierzytelniaczem. + + + Zaloguj się + + + To odzyskiwanie jest już nieważne. Zaloguj się i zacznij od nowa. + + + Odzyskiwanie jest chwilowo niedostępne. Zaloguj się i spróbuj ponownie z innym kodem odzyskiwania. + + + Jeden z kluczy dostępu do usunięcia nie należy do Ciebie lub został już usunięty. + + + Powrót z logowania Twojej organizacji… + + + Kontynuuj + + + Współdzielone stanowisko + + + Na współdzielonym stanowisku każde logowanie w tej przeglądarce blokuje się przy bezczynności, kończy się z limitem zmiany i nigdy nie zapamiętuje przeglądarki. Każdy operator loguje się na własne konto. + + + Używaj tej przeglądarki jako współdzielonego stanowiska + + + Nazwa stanowiska (opcjonalnie) + + + Nazwa dla wsparcia, np. „Stanowisko dyspozytorskie 2”. Określa stanowisko, a nie osobę. + + + Zapisz + + + Ta przeglądarka jest teraz współdzielonym stanowiskiem. Dotyczy to następnego logowania. + + + Ta przeglądarka nie jest już współdzielonym stanowiskiem. Dotyczy to następnego logowania. + + + Tryb współdzielonego stanowiska nie jest dostępny w tym wdrożeniu Resgrid. + + + Ustawienia współdzielonego stanowiska + + + Współdzielone stanowisko: logowania tutaj blokują się przy bezczynności i nigdy nie zapamiętują tej przeglądarki. + + + Współdzielone stanowisko ({0}): logowania tutaj blokują się przy bezczynności i nigdy nie zapamiętują tej przeglądarki. + + + Stanowisko zablokowane + + + To stanowisko zablokowało się z powodu bezczynności. + + + To stanowisko jest zablokowane. + + + Zalogowano jako {0} + + + Tylko {0} może je odblokować. Wszyscy inni: zmień operatora lub zakończ zmianę. + + + Odblokuj + + + Zmień operatora + + + Zakończ zmianę + + + Zablokuj + + + Pozostań zalogowany + + + Blokada za {0} + + + To stanowisko zablokuje się za {0} s z powodu bezczynności. + + + Zmiana kończy się za {0} min. + + + Szybkie odblokowanie wymaga aplikacji uwierzytelniającej lub innej metody weryfikacji. Zakończ zmianę i zaloguj się ponownie. + + + Twój dział wymaga logowania jednokrotnego. Zakończ zmianę i zaloguj się przez SSO. + + + Stanowisko zablokowało się ponownie. Odblokuj je jeszcze raz. + + + Ta sesja się zakończyła. Zakończ zmianę i zaloguj się ponownie. + + + Zbyt wiele prób odblokowania. Odczekaj kilka minut lub zakończ zmianę i zaloguj się ponownie. + + + Nie udało się zaktualizować sesji. Spróbuj ponownie. + + + Zmiana na tym współdzielonym stanowisku się zakończyła. Zaloguj się ponownie. + + + Zmiana zakończona. To stanowisko jest gotowe do następnego logowania. + + + Wylogowano. Następny operator może się teraz zalogować. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx index ed49a16cc..f59337eba 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.sv.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Du kan inte stänga av 2FA medan du har passkeys. Ta först bort dina passkeys och stäng sedan av 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Nycklar (passkeys) + + + En nyckel (passkey) verifierar dig med enhetens skärmlås eller en säkerhetsnyckel. Varje nyckel fungerar bara i appen där den lades till; lägg till en här för webben. När du tar bort en nyckel slutar den genast fungera i Resgrid, även om den kan finnas kvar på enheten eller i din lösenordshanterare. + + + Du har inga nycklar ännu. + + + Lägg till en nyckel för webben + + + Det går ännu inte att lägga till nycklar för webben i den här installationen. + + + Konfigurera en autentiseringsapp och spara återställningskoder innan du lägger till en nyckel. + + + Namn + + + Fungerar i + + + Tillagd + + + Senast använd + + + Inte ännu + + + Tillagd på en delad installation + + + Byt namn + + + Ta bort + + + Nytt namn på nyckeln: + + + Ta bort nyckeln? Den slutar genast fungera i Resgrid, och sessioner som loggades in med den avslutas. + + + Nyckeln har lagts till. + + + Nyckeln har tagits bort. + + + Nyckeldialogen stängdes. Inget ändrades. + + + Den här webbläsaren kan inte använda nycklar. Använd din autentiseringsapp i stället. + + + Nyckeln kunde inte användas. Försök igen eller använd din autentiseringsapp. + + + Du har redan det högsta tillåtna antalet nycklar för webben. Ta bort en först. + + + Använd en nyckel + + + Webben + + + Responder + + + Unit + + + Dispatch + + + IC + + + Nyckeln har bytt namn. + + + Den här enheten eller säkerhetsnyckeln har redan en nyckel för ditt konto i den här appen. + + + Lösenordet godkändes. Slutför inloggningen med en av dina verifieringsmetoder. + + + Godkänn med Responder + + + Öppna Responder i telefonen och godkänn begäran. Kontrollera att den visar det här numret: + + + Väntar på ditt godkännande i Responder… + + + Begäran nekades i Responder. Använd en annan metod. + + + Begäran upphörde innan den godkändes. Börja om. + + + Sluta vänta + + + Godkännande med Responder är inte tillgängligt just nu. Använd en annan metod. + + + Det fungerade inte. Försök igen eller använd en annan metod. + + + Inloggningen tog för lång tid. Logga in igen. + + + För många misslyckade försök. Vänta några minuter och logga in igen. + + + Organisationens inloggningspolicy har ändrats. Logga in igen. + + + Kontots inloggningsuppgifter har ändrats. Logga in igen. + + + Inloggningen är tillfälligt otillgänglig. Försök igen om en stund. + + + Den här inloggningen är inte längre giltig. Logga in igen. + + + Den verifieringsmetoden är inte tillgänglig för den här inloggningen. Använd en annan metod. + + + Organisationens högsta antal aktiva sessioner har nåtts. Avsluta en av dina sessioner eller kontakta din administratör. + + + Du loggade in med en återställningskod. Om du inte längre har din autentiseringsapp, ersätt den nu. + + + Använd en annan metod + + + Logga in med enkel inloggning + + + Enkel inloggning + + + Ange ditt användarnamn eller organisationens kod. Du loggar in med din organisations identitetsleverantör. + + + Användarnamn + + + eller + + + Organisationskod + + + Fortsätt + + + Logga in med lösenord i stället + + + Ange ditt användarnamn eller organisationens kod. + + + Enkel inloggning är inte tillgänglig för den här inloggningen. + + + Inloggningen kunde inte kopplas till den här webbläsaren. Börja om. + + + Identitetsleverantören loggade inte in dig. + + + Den enkla inloggningen kunde inte slutföras. Börja om. + + + Den enkla inloggningen tog för lång tid. Börja om. + + + Kontot kan inte logga in här med enkel inloggning. + + + Identitetsleverantören loggade in ett annat konto. Börja om med kontot du använder här. + + + Identitetsleverantören bekräftade ingen ny inloggning. Börja om. + + + Identitetsleverantören bekräftade inte multifaktorautentisering. Använd en annan metod. + + + Organisationen kräver multifaktorautentisering och kontot har ingen ännu. Fråga din administratör hur du konfigurerar den. + + + Verifiera med din identitetsleverantör + + + Bekräfta med enkel inloggning + + + För att testa mappningen krävs en nylig verifiering med din autentiseringsapp eller en nyckel. + + + Testinloggningen innehöll inget värde som mappningen räknar som MFA. Kontrollera mappningen och identitetsleverantören och testa igen. + + + Mappningen ändrades under testet. Testa den igen. + + + Mappningen klarade testet och gäller nu. + + + Konfigurera din autentiseringsapp + + + Organisationen kräver en andra faktor och kontot har ingen ännu. Konfigurera en autentiseringsapp för att slutföra inloggningen. + + + Verifiera och logga in + + + Konfigurationskoden har gått ut. En ny visas; skanna den igen. + + + Tillbaka till inloggningen + + + Jag har förlorat min autentiserare + + + Har du förlorat din autentiserare? + + + Om du har sparat dina återställningskoder anger du en för att konfigurera en ny autentiseringsapp och ta bort nycklar du har förlorat. Alla sessioner loggas ut och sedan loggar du in igen. + + + Ersätt min autentiserare + + + För att använda en återställningskod loggar du först in med lösenord eller enkel inloggning och väljer sedan ”Jag har förlorat min autentiserare”. + + + Ingen återställningskod? + + + Resgrid kan inte återställa en förlorad autentiserare automatiskt. Be organisationens administratörer kontakta Resgrids support, som bekräftar vem du är med organisationen innan något ändras. Följ organisationens egna rutiner tills dess. + + + Ersätt din autentiserare + + + Konfigurera din nya autentiseringsapp och ange koden den visar. Välj de nycklar du har förlorat; de tas bort. + + + Nycklar att ta bort som förlorade + + + Ersätt och logga ut överallt + + + Avbryt återställningen + + + Din autentiserare har ersatts + + + Alla sessioner har loggats ut. Spara dina nya återställningskoder och logga sedan in igen med din nya autentiserare. + + + Logga in + + + Den här återställningen är inte längre giltig. Logga in och börja om. + + + Återställningen är tillfälligt otillgänglig. Logga in och försök igen med en annan återställningskod. + + + En av nycklarna som ska tas bort är inte din eller har redan tagits bort. + + + Återvänder från din organisations inloggning… + + + Fortsätt + + + Delad arbetsstation + + + På en delad arbetsstation låses varje inloggning i den här webbläsaren vid inaktivitet, avslutas vid skiftgränsen och kommer aldrig ihåg webbläsaren. Varje operatör loggar in med sitt eget konto. + + + Använd den här webbläsaren som delad arbetsstation + + + Arbetsstationens namn (valfritt) + + + Ett namn för supporten, till exempel "Larmplats 2". Det identifierar stationen, inte en person. + + + Spara + + + Den här webbläsaren är nu en delad arbetsstation. Det gäller från nästa inloggning. + + + Den här webbläsaren är inte längre en delad arbetsstation. Det gäller från nästa inloggning. + + + Läget för delad arbetsstation är inte tillgängligt i den här Resgrid-installationen. + + + Inställningar för delad arbetsstation + + + Delad arbetsstation: inloggningar här låses vid inaktivitet och kommer aldrig ihåg den här webbläsaren. + + + Delad arbetsstation ({0}): inloggningar här låses vid inaktivitet och kommer aldrig ihåg den här webbläsaren. + + + Arbetsstationen är låst + + + Den här arbetsstationen låstes på grund av inaktivitet. + + + Den här arbetsstationen är låst. + + + Inloggad som {0} + + + Endast {0} kan låsa upp den. Alla andra: byt operatör eller avsluta skiftet. + + + Lås upp + + + Byt operatör + + + Avsluta skift + + + Lås + + + Förbli inloggad + + + Låses om {0} + + + Den här arbetsstationen låses om {0} sekunder på grund av inaktivitet. + + + Skiftet slutar om {0} minuter. + + + Snabb upplåsning kräver en autentiseringsapp eller en annan verifieringsmetod. Avsluta skiftet och logga in igen. + + + Din avdelning kräver enkel inloggning. Avsluta skiftet och logga in med SSO. + + + Arbetsstationen låstes igen. Lås upp den igen. + + + Den här sessionen har avslutats. Avsluta skiftet och logga in igen. + + + För många upplåsningsförsök. Vänta några minuter eller avsluta skiftet och logga in igen. + + + Sessionen kunde inte uppdateras. Försök igen. + + + Skiftet på den här delade arbetsstationen har slutat. Logga in igen. + + + Skiftet avslutat. Den här arbetsstationen är redo för nästa inloggning. + + + Utloggad. Nästa operatör kan logga in nu. + diff --git a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx index ed49a16cc..fbfd33196 100644 --- a/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/TwoFactor/TwoFactor.uk.resx @@ -155,6 +155,9 @@ Enter your current authenticator code to confirm you wish to disable 2FA: + + Ви не можете вимкнути 2FA, доки маєте ключі доступу. Спершу видаліть ключі доступу, а потім вимкніть 2FA. + Authenticator Code @@ -212,4 +215,421 @@ Invalid recovery code. + + Replace Authenticator + + + Replace Your Authenticator App + + + Scan this new key with the authenticator app you want to use, then enter a code from it. When you confirm, your old authenticator stops working, new recovery codes are issued, and every device is signed out. + + + Verify, Replace and Sign Out Everywhere + + + Your authenticator was replaced and every session was signed out. Save these recovery codes, then sign in again with your new authenticator. + + + Sign In Again + + + Ключі доступу + + + Ключ доступу підтверджує вашу особу за допомогою блокування екрана пристрою або ключа безпеки. Кожен ключ доступу працює лише в застосунку, у якому його додано; додайте тут ключ для вебверсії. Видалений ключ доступу одразу перестає працювати в Resgrid, хоча він може залишитися на пристрої чи в менеджері паролів. + + + У вас ще немає ключів доступу. + + + Додати ключ доступу для вебверсії + + + Додавання ключів доступу для вебверсії в цьому розгортанні ще недоступне. + + + Перш ніж додати ключ доступу, налаштуйте застосунок автентифікації та збережіть коди відновлення. + + + Назва + + + Працює в + + + Додано + + + Востаннє використано + + + Ще ні + + + Додано на спільній інсталяції + + + Перейменувати + + + Видалити + + + Нова назва цього ключа доступу: + + + Видалити цей ключ доступу? Він одразу перестане працювати в Resgrid, а сеанси, у які з ним увійшли, завершаться. + + + Ключ доступу додано. + + + Ключ доступу видалено. + + + Вікно ключа доступу закрито. Нічого не змінено. + + + Цей браузер не підтримує ключі доступу. Скористайтеся застосунком автентифікації. + + + Не вдалося скористатися ключем доступу. Спробуйте ще раз або скористайтеся застосунком автентифікації. + + + У вас уже максимальна дозволена кількість ключів доступу для вебверсії. Спочатку видаліть один. + + + Використати ключ доступу + + + Вебверсія + + + Responder + + + Unit + + + Dispatch + + + IC + + + Ключ доступу перейменовано. + + + Цей пристрій або ключ безпеки вже містить ключ доступу до вашого облікового запису в цьому застосунку. + + + Пароль прийнято. Завершіть вхід одним зі своїх способів підтвердження. + + + Схвалити в Responder + + + Відкрийте Responder на телефоні та схваліть запит. Перевірте, що він показує це число: + + + Очікуємо на ваше схвалення в Responder… + + + Запит відхилено в Responder. Скористайтеся іншим способом. + + + Запит завершився до схвалення. Почніть знову. + + + Припинити очікування + + + Схвалення в Responder зараз недоступне. Скористайтеся іншим способом. + + + Не вдалося. Спробуйте ще раз або скористайтеся іншим способом. + + + Вхід тривав надто довго. Увійдіть знову. + + + Забагато невдалих спроб. Зачекайте кілька хвилин і увійдіть знову. + + + Політика входу вашого підрозділу змінилася. Увійдіть знову. + + + Дані входу вашого облікового запису змінилися. Увійдіть знову. + + + Вхід тимчасово недоступний. Спробуйте ще раз за мить. + + + Цей вхід більше не дійсний. Увійдіть знову. + + + Цей спосіб підтвердження недоступний для цього входу. Скористайтеся іншим способом. + + + Досягнуто максимальної кількості активних сеансів вашого підрозділу. Завершіть один зі своїх сеансів або зверніться до адміністратора. + + + Ви увійшли за допомогою коду відновлення. Якщо у вас більше немає застосунку автентифікації, замініть його зараз. + + + Використати інший спосіб + + + Увійти через єдиний вхід + + + Єдиний вхід + + + Введіть своє ім’я користувача або код вашого підрозділу. Ви увійдете через постачальника ідентичності вашої організації. + + + Ім’я користувача + + + або + + + Код підрозділу + + + Продовжити + + + Увійти з паролем + + + Введіть ім’я користувача або код підрозділу. + + + Єдиний вхід недоступний для цього входу. + + + Не вдалося зіставити цей вхід із цим браузером. Почніть знову. + + + Ваш постачальник ідентичності не виконав вхід. + + + Не вдалося завершити єдиний вхід. Почніть знову. + + + Єдиний вхід тривав надто довго. Почніть знову. + + + Цей обліковий запис не може увійти тут через єдиний вхід. + + + Постачальник ідентичності виконав вхід іншим обліковим записом. Почніть знову з обліковим записом, який ви використовуєте тут. + + + Постачальник ідентичності не підтвердив новий вхід. Почніть знову. + + + Постачальник ідентичності не підтвердив багатофакторну автентифікацію. Скористайтеся іншим способом. + + + Ваш підрозділ вимагає багатофакторної автентифікації, а цей обліковий запис її ще не має. Запитайте адміністратора, як її налаштувати. + + + Підтвердити через постачальника ідентичності + + + Підтвердити через єдиний вхід + + + Для перевірки зіставлення потрібне недавнє підтвердження застосунком автентифікації або ключем доступу. + + + Тестовий вхід не містив значення, яке зіставлення вважає MFA. Перевірте зіставлення та постачальника ідентичності й повторіть перевірку. + + + Зіставлення змінилося під час перевірки. Перевірте його знову. + + + Зіставлення пройшло перевірку й тепер чинне. + + + Налаштуйте застосунок автентифікації + + + Ваш підрозділ вимагає другого фактора, а цей обліковий запис його ще не має. Налаштуйте застосунок автентифікації, щоб завершити вхід. + + + Підтвердити й увійти + + + Термін дії коду налаштування минув. Показано новий; відскануйте його знову. + + + Повернутися до входу + + + Я втратив автентифікатор + + + Втратили автентифікатор? + + + Якщо ви зберегли коди відновлення, введіть один, щоб налаштувати новий застосунок автентифікації та видалити втрачені ключі доступу. Усі сеанси завершаться, після чого ви ввійдете знову. + + + Замінити автентифікатор + + + Щоб використати код відновлення, спершу увійдіть із паролем або через єдиний вхід, а потім виберіть «Я втратив автентифікатор». + + + Немає коду відновлення? + + + Resgrid не може автоматично скинути втрачений автентифікатор. Попросіть адміністраторів вашого підрозділу звернутися до служби підтримки Resgrid, яка підтвердить вашу особу з підрозділом, перш ніж щось зміниться. Доти дотримуйтеся процедур вашого підрозділу. + + + Замініть автентифікатор + + + Налаштуйте новий застосунок автентифікації та введіть показаний код. Виберіть втрачені ключі доступу; їх буде видалено. + + + Ключі доступу, які слід видалити як втрачені + + + Замінити й вийти всюди + + + Скасувати відновлення + + + Автентифікатор замінено + + + Усі сеанси завершено. Збережіть нові коди відновлення, а потім увійдіть знову з новим автентифікатором. + + + Увійти + + + Це відновлення більше не дійсне. Увійдіть і почніть знову. + + + Відновлення тимчасово недоступне. Увійдіть і спробуйте знову з іншим кодом відновлення. + + + Один із ключів доступу для видалення не ваш або вже видалений. + + + Повернення зі входу вашої організації… + + + Продовжити + + + Спільна робоча станція + + + На спільній робочій станції кожен вхід у цьому браузері блокується під час бездіяльності, завершується з лімітом зміни й ніколи не запам’ятовує браузер. Кожен оператор входить під власним обліковим записом. + + + Використовувати цей браузер як спільну робочу станцію + + + Назва робочої станції (необов’язково) + + + Назва для підтримки, наприклад «Диспетчерське місце 2». Вона позначає станцію, а не людину. + + + Зберегти + + + Цей браузер тепер є спільною робочою станцією. Це діє з наступного входу. + + + Цей браузер більше не є спільною робочою станцією. Це діє з наступного входу. + + + Режим спільної робочої станції недоступний у цьому розгортанні Resgrid. + + + Налаштування спільної робочої станції + + + Спільна робоча станція: входи тут блокуються під час бездіяльності й ніколи не запам’ятовують цей браузер. + + + Спільна робоча станція ({0}): входи тут блокуються під час бездіяльності й ніколи не запам’ятовують цей браузер. + + + Робочу станцію заблоковано + + + Цю робочу станцію заблоковано через бездіяльність. + + + Цю робочу станцію заблоковано. + + + Увійшли як {0} + + + Розблокувати може лише {0}. Усі інші: змініть оператора або завершіть зміну. + + + Розблокувати + + + Змінити оператора + + + Завершити зміну + + + Заблокувати + + + Залишитися в системі + + + Блокування через {0} + + + Ця робоча станція заблокується через {0} с через бездіяльність. + + + Зміна завершиться через {0} хв. + + + Для швидкого розблокування потрібен застосунок автентифікації або інший спосіб перевірки. Завершіть зміну та увійдіть знову. + + + Ваш підрозділ вимагає єдиного входу. Завершіть зміну та увійдіть через SSO. + + + Робочу станцію знову заблоковано. Розблокуйте її ще раз. + + + Цей сеанс завершено. Завершіть зміну та увійдіть знову. + + + Забагато спроб розблокування. Зачекайте кілька хвилин або завершіть зміну та увійдіть знову. + + + Не вдалося оновити сеанс. Спробуйте ще раз. + + + Зміна на цій спільній робочій станції завершилася. Увійдіть знову. + + + Зміну завершено. Ця робоча станція готова до наступного входу. + + + Ви вийшли. Наступний оператор може увійти зараз. + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx index 3a1747562..81991691d 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.ar.resx @@ -231,4 +231,280 @@ مطلوب؟ + + إضافة ربط تتبع + + + شبكات المصدر المسموح بها + + + نطاقات CIDR اختيارية من IPv4 أو IPv6، مفصولة بفواصل، يُسمح لها بإرسال المواقع لجهاز التتبع هذا. + + + وضع المصادقة + + + اسم المستخدم لمصادقة Basic + + + اسم المستخدم لمصادقة Basic مطلوب. + + + حالة التصديق + + + نسخ + + + إنشاء بيانات اعتماد + + + بادئة بيانات الاعتماد + + + لقد حفظت بيانات الاعتماد هذه + + + بيانات الاعتماد + + + رمز بيانات الاعتماد + + + اسم الترويسة المخصصة مطلوب. + + + هل تريد إزالة ربط التتبع هذا؟ ستُبطَل بيانات الاعتماد الخاصة به وسيتوقف عن قبول المواقع. + + + معرّف الجهاز + + + استخدم المعرّف الذي يرسله الجهاز أو خدمة إعادة التوجيه. يُوحَّد تنسيقه قبل التخزين. + + + تعطيل التتبع + + + هل تريد تعطيل هذا الربط وإبطال جميع بيانات الاعتماد الخاصة به؟ + + + تعديل ربط التتبع + + + نقطة نهاية HTTPS + + + تاريخ الانتهاء + + + إصدار البرنامج الثابت + + + تتبع GPS عبر الأجهزة + + + اربط جهاز تتبع أو خدمة إعادة توجيه بهذه الوحدة، وأصدر بيانات الاعتماد، وراقب حالة التسليم. + + + اسم الترويسة + + + قيمة الترويسة + + + آخر رمز خطأ + + + آخر استلام + + + آخر ظهور + + + آخر استخدام + + + آخر تحديد موقع صالح + + + حمولة JSON + + + أبداً + + + لم تُصدَر أي بيانات اعتماد لهذا الربط. + + + لم تُهيَّأ أي عمليات ربط لتتبع الأجهزة لهذه الوحدة. + + + حفظ بيانات اعتماد التتبع + + + تُعرض بيانات الاعتماد هذه مرة واحدة فقط. انسخ نقطة النهاية والسر إلى إعدادات جهاز التتبع قبل مغادرة هذه الصفحة. + + + البروتوكول + + + التحقق من JSON التجريبي + + + حمولة JSON غير سليمة البنية، أو تحتوي على حقول مكررة، أو تتجاوز حد التداخل. + + + يتطلب كل موقع قيمة eventId غير فارغة وقيمتين صالحتين لخط العرض وخط الطول. + + + يجب أن تحتوي الدفعة على كائن موقع JSON واحد على الأقل. + + + أدخل حمولة JSON للتحقق منها. + + + عدد المواقع التي قبلها محلل المعاينة: {0}. لم يُوضع أي شيء في قائمة الانتظار ولم يُخزَّن أي شيء. + + + تتجاوز حمولة JSON حد حجم الطلب المهيأ. + + + تتجاوز دفعة JSON حد المواقع المهيأ. + + + إبطال + + + هل تريد إبطال بيانات الاعتماد هذه؟ لن يتمكن المُرسِل من استخدامها بعد الآن. + + + سلوك إعادة المحاولة المتوقع: + + + مُبطَلة + + + تدوير + + + حفظ ربط التتبع + + + إرسال JSON تجريبي + + + تحقّق من حمولة Resgrid JSON عامة دون مصادقتها أو وضعها في قائمة الانتظار أو تخزينها. هذه المعاينة متاحة فقط لمسؤولي القسم خارج بيئة الإنتاج. + + + تعليمات الإعداد + + + المعرّف الثانوي + + + اختر ملف تعريف تتبع مصدَّقًا + + + اختر ملف تعريف تتبع مصدَّقًا. + + + أولوية المصدر + + + إجراءات ربط التتبع + + + تم إنشاء ربط التتبع. أنشئ بيانات اعتماد لإكمال الإعداد. + + + تمت إزالة ربط التتبع. + + + تم تعطيل ربط التتبع وإبطال بيانات الاعتماد الخاصة به. + + + تم تحديث ربط التتبع. + + + تم إبطال بيانات اعتماد التتبع. + + + الاسم المعروض + + + مفعّل + + + معرّف الجهاز مطلوب لملف التعريف المحدد. + + + ملف تعريف التتبع + + + حالة التتبع + + + آلية النقل + + + وضع المصادقة المحدد غير مدعوم في ملف تعريف التتبع هذا. + + + عرض حالة التتبع + + + رمز Bearer + + + مصادقة Basic + + + ترويسة مخصصة + + + عنوان URL يتضمن الرمز في المسار + + + HTTPS أصلي + + + HTTPS مُدار (JSON) + + + TCP/UDP أصلي + + + بوابة البروتوكول + + + لم يظهر بعد + + + متصل + + + لا توجد بيانات حديثة + + + خطأ + + + معطّل + + + مرشّح + + + تم التحقق باستخدام بيانات نموذجية + + + تم التحقق على الأجهزة + + + مصدَّق + + + مُهمَل + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx index fbeffa302..4fcdf8d8d 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.de.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Protokoll hinzufügen - Add Role + Rolle hinzufügen - Add Unit Log + Einheitenprotokoll hinzufügen - Are you sure you want to permanently delete all statuses for this unit? + Möchten Sie wirklich alle Status dieser Einheit dauerhaft löschen? - Clear out all Statuses For Unit + Alle Status der Einheit löschen - Delete All + Alle löschen - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + WARNUNG: Dadurch wird diese Einheit dauerhaft gelöscht. Einheit wirklich löschen: - Edit Unit + Einheit bearbeiten - Events for + Ereignisse für - Generate Report + Bericht erstellen - Log Body (Narrative) + Protokolltext (Freitext) - A Narrative for the unit log + Freitext für das Einheitenprotokoll Protokolle - New Unit + Neue Einheit - Not Occupied + Nicht besetzt - No Un-Grouped Units + Keine nicht gruppierten Einheiten - No Units in Department + Keine Einheiten in der Abteilung - No Units in this Group + Keine Einheiten in dieser Gruppe - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + Um die Besetzung von Einheiten festzulegen, müssen Einheiten angelegt und in diesen Einheiten Rollen (z. B. Fahrer, Maschinist usw.) erstellt sein. - Remove this role + Diese Rolle entfernen - Role Name + Rollenname - Role Name + Rollenname - Select Unit + Einheit auswählen - Set Status for Units + Status für Einheiten festlegen - Set Status + Status festlegen - Set Status for Selected Units + Status für ausgewählte Einheiten festlegen - Set Unit Status + Einheitenstatus festlegen - You have selected a unit, only other units with the same custom (or default) states can be selected. + Sie haben eine Einheit ausgewählt. Es können nur weitere Einheiten mit denselben benutzerdefinierten (oder Standard-)Status ausgewählt werden. - Unit Staffing + Einheitenbesetzung - Unit Staffing + Einheitenbesetzung - View Events + Ereignisse anzeigen - View Unit Events + Einheitenereignisse anzeigen - View Unit Logs + Einheitenprotokolle anzeigen - Yes I'm sure + Ja, ich bin sicher - You can add a new Type + Einen neuen Typ können Sie - here + hier hinzufügen Erforderliche Personalrolle @@ -182,4 +173,280 @@ Erforderlich? + + Tracker-Zuordnung hinzufügen + + + Zulässige Quellnetze + + + Optionale, durch Kommas getrennte IPv4- oder IPv6-CIDR-Bereiche, die Positionen für diesen Tracker senden dürfen. + + + Authentifizierungsmodus + + + Benutzername für die Basic-Authentifizierung + + + Ein Benutzername für die Basic-Authentifizierung ist erforderlich. + + + Zertifizierungsstatus + + + Kopieren + + + Zugangsdaten erstellen + + + Präfix der Zugangsdaten + + + Ich habe diese Zugangsdaten gespeichert + + + Zugangsdaten + + + Zugangstoken + + + Ein benutzerdefinierter Header-Name ist erforderlich. + + + Diese Tracker-Zuordnung entfernen? Ihre Zugangsdaten werden widerrufen, und sie nimmt keine Positionen mehr an. + + + Gerätekennung + + + Verwenden Sie die Kennung, die das Gerät oder der Weiterleitungsdienst sendet. Sie wird vor dem Speichern normalisiert. + + + Ortung deaktivieren + + + Diese Zuordnung deaktivieren und alle ihre Zugangsdaten widerrufen? + + + Tracker-Zuordnung bearbeiten + + + HTTPS-Endpunkt + + + Gültig bis + + + Firmware-Version + + + Hardware-GPS-Ortung + + + Ordnen Sie dieser Einheit einen Hardware-Tracker oder Weiterleitungsdienst zu, stellen Sie Zugangsdaten aus und überwachen Sie den Übermittlungsstatus. + + + Header-Name + + + Header-Wert + + + Letzter Fehlercode + + + Zuletzt empfangen + + + Zuletzt gesehen + + + Zuletzt verwendet + + + Letzte gültige Position + + + JSON-Nutzdaten + + + Niemals + + + Für diese Zuordnung wurden keine Zugangsdaten ausgestellt. + + + Für diese Einheit sind keine Hardware-Tracker-Zuordnungen konfiguriert. + + + Zugangsdaten für die Ortung speichern + + + Diese Zugangsdaten werden nur einmal angezeigt. Kopieren Sie Endpunkt und Geheimschlüssel in die Tracker-Konfiguration, bevor Sie diese Seite verlassen. + + + Protokoll + + + Test-JSON prüfen + + + Die JSON-Nutzdaten sind fehlerhaft, enthalten doppelte Felder oder überschreiten die maximale Verschachtelungstiefe. + + + Jede Position benötigt eine nicht leere eventId sowie gültige Werte für Breiten- und Längengrad. + + + Ein Batch muss mindestens ein JSON-Positionsobjekt enthalten. + + + Geben Sie JSON-Nutzdaten zur Prüfung ein. + + + Der Vorschau-Parser hat {0} Position(en) akzeptiert. Es wurde nichts in die Warteschlange gestellt oder gespeichert. + + + Die JSON-Nutzdaten überschreiten die konfigurierte maximale Anfragegröße. + + + Der JSON-Batch überschreitet die konfigurierte maximale Anzahl an Positionen. + + + Widerrufen + + + Diese Zugangsdaten widerrufen? Der Absender kann sie danach nicht mehr verwenden. + + + Erwartetes Wiederholungsverhalten: + + + Widerrufen + + + Erneuern + + + Tracker-Zuordnung speichern + + + Test-JSON senden + + + Prüft generische Resgrid-JSON-Nutzdaten, ohne sie zu authentifizieren, in die Warteschlange zu stellen oder zu speichern. Diese Vorschau steht nur Abteilungsadministratoren außerhalb der Produktionsumgebung zur Verfügung. + + + Einrichtungsanleitung + + + Sekundäre Kennung + + + Zertifiziertes Ortungsprofil auswählen + + + Wählen Sie ein zertifiziertes Ortungsprofil aus. + + + Quellenpriorität + + + Aktionen für die Tracker-Zuordnung + + + Tracker-Zuordnung erstellt. Erzeugen Sie Zugangsdaten, um die Einrichtung abzuschließen. + + + Tracker-Zuordnung entfernt. + + + Tracker-Zuordnung deaktiviert und ihre Zugangsdaten widerrufen. + + + Tracker-Zuordnung aktualisiert. + + + Zugangsdaten für die Ortung widerrufen. + + + Anzeigename + + + Aktiviert + + + Für das ausgewählte Profil ist eine Gerätekennung erforderlich. + + + Ortungsprofil + + + Ortungsstatus + + + Übertragungsweg + + + Der ausgewählte Authentifizierungsmodus wird von diesem Ortungsprofil nicht unterstützt. + + + Ortungsstatus anzeigen + + + Bearer-Token + + + Basic-Authentifizierung + + + Benutzerdefinierter Header + + + URL mit Token im Pfad + + + Natives HTTPS + + + Verwaltetes HTTPS (JSON) + + + Natives TCP/UDP + + + Protokoll-Gateway + + + Noch nie gesehen + + + Online + + + Keine aktuellen Daten + + + Fehler + + + Deaktiviert + + + Kandidat + + + Mit Beispieldaten geprüft + + + Auf Hardware geprüft + + + Zertifiziert + + + Abgekündigt + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx index 2e074848a..04507ec3a 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.el.resx @@ -231,4 +231,280 @@ Απαιτείται; + + Προσθήκη σύνδεσης εντοπισμού + + + Επιτρεπόμενα δίκτυα προέλευσης + + + Προαιρετικές περιοχές CIDR IPv4 ή IPv6, χωρισμένες με κόμμα, από τις οποίες επιτρέπεται η αποστολή θέσεων για αυτή τη συσκευή εντοπισμού. + + + Τρόπος ελέγχου ταυτότητας + + + Όνομα χρήστη για έλεγχο ταυτότητας Basic + + + Απαιτείται όνομα χρήστη για έλεγχο ταυτότητας Basic. + + + Κατάσταση πιστοποίησης + + + Αντιγραφή + + + Δημιουργία διαπιστευτηρίου + + + Πρόθεμα διαπιστευτηρίου + + + Έχω αποθηκεύσει αυτό το διαπιστευτήριο + + + Διαπιστευτήρια + + + Διακριτικό διαπιστευτηρίου + + + Απαιτείται όνομα προσαρμοσμένης κεφαλίδας. + + + Αφαίρεση αυτής της σύνδεσης εντοπισμού; Τα διαπιστευτήριά της θα ανακληθούν και θα σταματήσει να δέχεται θέσεις. + + + Αναγνωριστικό συσκευής + + + Χρησιμοποιήστε το αναγνωριστικό που εκπέμπει η συσκευή ή η υπηρεσία προώθησης. Κανονικοποιείται πριν από την αποθήκευση. + + + Απενεργοποίηση εντοπισμού + + + Απενεργοποίηση αυτής της σύνδεσης και ανάκληση όλων των διαπιστευτηρίων της; + + + Επεξεργασία σύνδεσης εντοπισμού + + + Τελικό σημείο HTTPS + + + Λήγει + + + Έκδοση υλικολογισμικού + + + Εντοπισμός GPS μέσω συσκευής + + + Συνδέστε μια συσκευή εντοπισμού ή μια υπηρεσία προώθησης με αυτή τη μονάδα, εκδώστε διαπιστευτήρια και παρακολουθήστε την κατάσταση παράδοσης των δεδομένων. + + + Όνομα κεφαλίδας + + + Τιμή κεφαλίδας + + + Τελευταίος κωδικός σφάλματος + + + Τελευταία λήψη + + + Τελευταία δραστηριότητα + + + Τελευταία χρήση + + + Τελευταίο έγκυρο στίγμα + + + Ωφέλιμο φορτίο JSON + + + Ποτέ + + + Δεν έχουν εκδοθεί διαπιστευτήρια για αυτή τη σύνδεση. + + + Δεν έχουν διαμορφωθεί συνδέσεις εντοπισμού μέσω συσκευής για αυτή τη μονάδα. + + + Αποθήκευση διαπιστευτηρίου εντοπισμού + + + Αυτό το διαπιστευτήριο εμφανίζεται μόνο μία φορά. Αντιγράψτε το τελικό σημείο και το μυστικό στη διαμόρφωση της συσκευής εντοπισμού πριν φύγετε από αυτή τη σελίδα. + + + Πρωτόκολλο + + + Επικύρωση δοκιμαστικού JSON + + + Το ωφέλιμο φορτίο JSON είναι κακοσχηματισμένο, περιέχει διπλότυπα πεδία ή υπερβαίνει το όριο εμφώλευσης. + + + Κάθε θέση απαιτεί μη κενό eventId και έγκυρες τιμές γεωγραφικού πλάτους και μήκους. + + + Μια δέσμη πρέπει να περιέχει τουλάχιστον ένα αντικείμενο θέσης JSON. + + + Εισαγάγετε ωφέλιμο φορτίο JSON για επικύρωση. + + + Θέσεις που αποδέχθηκε ο αναλυτής προεπισκόπησης: {0}. Τίποτα δεν τέθηκε σε ουρά ούτε αποθηκεύτηκε. + + + Το ωφέλιμο φορτίο JSON υπερβαίνει το διαμορφωμένο όριο μεγέθους αιτήματος. + + + Η δέσμη JSON υπερβαίνει το διαμορφωμένο όριο θέσεων. + + + Ανάκληση + + + Ανάκληση αυτού του διαπιστευτηρίου; Ο αποστολέας δεν θα μπορεί πλέον να το χρησιμοποιεί. + + + Αναμενόμενη συμπεριφορά επαναποστολής: + + + Ανακλήθηκε + + + Εναλλαγή + + + Αποθήκευση σύνδεσης εντοπισμού + + + Αποστολή δοκιμαστικού JSON + + + Επικυρώστε ένα γενικό ωφέλιμο φορτίο JSON του Resgrid χωρίς έλεγχο ταυτότητας, τοποθέτηση σε ουρά ή αποθήκευση. Αυτή η προεπισκόπηση είναι διαθέσιμη μόνο στους διαχειριστές τμήματος και μόνο εκτός του περιβάλλοντος παραγωγής. + + + Οδηγίες ρύθμισης + + + Δευτερεύον αναγνωριστικό + + + Επιλέξτε πιστοποιημένο προφίλ εντοπισμού + + + Επιλέξτε πιστοποιημένο προφίλ εντοπισμού. + + + Προτεραιότητα πηγής + + + Ενέργειες σύνδεσης εντοπισμού + + + Η σύνδεση εντοπισμού δημιουργήθηκε. Δημιουργήστε ένα διαπιστευτήριο για να ολοκληρώσετε τη ρύθμιση. + + + Η σύνδεση εντοπισμού αφαιρέθηκε. + + + Η σύνδεση εντοπισμού απενεργοποιήθηκε και τα διαπιστευτήριά της ανακλήθηκαν. + + + Η σύνδεση εντοπισμού ενημερώθηκε. + + + Το διαπιστευτήριο εντοπισμού ανακλήθηκε. + + + Εμφανιζόμενο όνομα + + + Ενεργή + + + Απαιτείται αναγνωριστικό συσκευής για το επιλεγμένο προφίλ. + + + Προφίλ εντοπισμού + + + Κατάσταση εντοπισμού + + + Μεταφορά + + + Ο επιλεγμένος τρόπος ελέγχου ταυτότητας δεν υποστηρίζεται από αυτό το προφίλ εντοπισμού. + + + Προβολή κατάστασης εντοπισμού + + + Διακριτικό Bearer + + + Έλεγχος ταυτότητας Basic + + + Προσαρμοσμένη κεφαλίδα + + + Διεύθυνση URL με διακριτικό στη διαδρομή + + + Εγγενές HTTPS + + + Διαχειριζόμενο HTTPS (JSON) + + + Εγγενές TCP/UDP + + + Πύλη πρωτοκόλλου + + + Καμία δραστηριότητα ακόμη + + + Σε σύνδεση + + + Χωρίς πρόσφατα δεδομένα + + + Σφάλμα + + + Ανενεργή + + + Υποψήφιο + + + Επαληθευμένο με δείγματα δεδομένων + + + Επαληθευμένο σε υλικό + + + Πιστοποιημένο + + + Καταργημένο + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx index 1156e7c0c..bcab1c8e5 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.en.resx @@ -231,4 +231,280 @@ Required? + + Add tracking binding + + + Allowed source networks + + + Optional comma-separated IPv4 or IPv6 CIDR ranges permitted to send positions for this tracker. + + + Authentication mode + + + Basic authentication username + + + A Basic authentication username is required. + + + Certification status + + + Copy + + + Create credential + + + Credential prefix + + + I have saved this credential + + + Credentials + + + Credential token + + + A custom header name is required. + + + Remove this tracking binding? Its credentials will be revoked and it will stop accepting positions. + + + Device identifier + + + Use the identifier emitted by the device or forwarding service. It is normalized before storage. + + + Disable tracking + + + Disable this binding and revoke all of its credentials? + + + Edit tracking binding + + + HTTPS endpoint + + + Expires + + + Firmware version + + + Hardware GPS tracking + + + Bind a hardware tracker or forwarding service to this Unit, issue credentials, and monitor delivery health. + + + Header name + + + Header value + + + Last error code + + + Last received + + + Last seen + + + Last used + + + Last valid fix + + + JSON payload + + + Never + + + No credentials have been issued for this binding. + + + No hardware tracking bindings are configured for this Unit. + + + Save tracking credential + + + This credential is shown only once. Copy the endpoint and secret into the tracker configuration before leaving this page. + + + Protocol + + + Validate test JSON + + + The JSON payload is malformed, contains duplicate fields, or exceeds the nesting limit. + + + Each position requires a non-empty eventId and valid latitude and longitude values. + + + A batch must contain at least one JSON position object. + + + Enter a JSON payload to validate. + + + The preview parser accepted {0} position(s). Nothing was queued or stored. + + + The JSON payload exceeds the configured request size limit. + + + The JSON batch exceeds the configured position limit. + + + Revoke + + + Revoke this credential? The sender will no longer be able to use it. + + + Retry expectation: + + + Revoked + + + Rotate + + + Save tracking binding + + + Send test JSON + + + Validate a generic Resgrid JSON payload without authenticating, queueing, or storing it. This preview is available only to department administrators outside production. + + + Setup instructions + + + Secondary identifier + + + Select a certified tracking profile + + + Select a certified tracking profile. + + + Source priority + + + Tracking binding actions + + + Tracking binding created. Generate a credential to finish setup. + + + Tracking binding removed. + + + Tracking binding disabled and its credentials revoked. + + + Tracking binding updated. + + + Tracking credential revoked. + + + Display name + + + Enabled + + + A device identifier is required for the selected profile. + + + Tracking profile + + + Tracking status + + + Transport + + + The selected authentication mode is not supported by this tracking profile. + + + View tracking status + + + Bearer token + + + Basic authentication + + + Custom header + + + Capability URL (token in path) + + + Native HTTPS + + + Managed HTTPS (JSON) + + + Native TCP/UDP + + + Protocol gateway + + + Never seen + + + Online + + + Stale + + + Error + + + Disabled + + + Candidate + + + Verified with sample data + + + Verified on hardware + + + Certified + + + Deprecated + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx index 83a85c922..4b7315b83 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.es.resx @@ -118,28 +118,28 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Agregar registro + Agregar bitácora Agregar rol - Agregar registro de unidades + Agregar bitácora de unidad - ¿Estás seguro de que quieres eliminar permanentemente todos los estados para esta unidad? + ¿Está seguro de que desea eliminar permanentemente todos los estados de esta unidad? - Borrar todos los estados para la unidad + Borrar todos los estados de la unidad Eliminar todos - ADVERTENCIA: Esto eliminará permanentemente esta unidad. ¿Estás seguro de que quieres eliminar la unidad? + ADVERTENCIA: Esto eliminará permanentemente esta unidad. ¿Está seguro de que desea eliminar la unidad - Unidad de edición + Editar unidad Eventos para @@ -148,13 +148,13 @@ Generar informe - Cuerpo de registro (narrativa) + Cuerpo de la bitácora (narrativa) - Una narrativa para el registro de la unidad + Una narrativa para la bitácora de la unidad - Registro + Bitácoras Nueva unidad @@ -172,7 +172,7 @@ No hay unidades en este grupo - Eliminar este papel + Eliminar este rol Nombre de rol @@ -181,7 +181,7 @@ Nombre de rol - Unidad de selección + Seleccionar unidad Establecer el estado de las unidades @@ -190,7 +190,7 @@ Establecer el estado - Estado establecido para unidades seleccionadas + Establecer el estado de las unidades seleccionadas Establecer el estado de la unidad @@ -211,13 +211,13 @@ Ver eventos de la unidad - Ver registros de unidades + Ver bitácoras de unidades - Sí estoy seguro + Sí, confirmo - Puedes agregar un nuevo tipo + Puede agregar un nuevo tipo aquí @@ -228,4 +228,283 @@ ¿Requerido? + + Para asignar personal a las unidades, debe tener unidades creadas y roles (p. ej., conductor, operador, etc.) definidos en esas unidades. + + + Agregar vinculación de rastreo + + + Redes de origen permitidas + + + Rangos CIDR IPv4 o IPv6 opcionales, separados por comas, autorizados para enviar posiciones de este rastreador. + + + Modo de autenticación + + + Nombre de usuario de autenticación Basic + + + Se requiere un nombre de usuario de autenticación Basic. + + + Estado de certificación + + + Copiar + + + Crear credencial + + + Prefijo de la credencial + + + He guardado esta credencial + + + Credenciales + + + Token de la credencial + + + Se requiere un nombre de encabezado personalizado. + + + ¿Quitar esta vinculación de rastreo? Se revocarán sus credenciales y dejará de aceptar posiciones. + + + Identificador del dispositivo + + + Use el identificador que emite el dispositivo o el servicio de reenvío. Se normaliza antes de almacenarse. + + + Deshabilitar rastreo + + + ¿Deshabilitar esta vinculación y revocar todas sus credenciales? + + + Editar vinculación de rastreo + + + Punto de conexión HTTPS + + + Caduca + + + Versión de firmware + + + Rastreo GPS por hardware + + + Vincule un rastreador de hardware o un servicio de reenvío a esta unidad, emita credenciales y supervise el estado de la entrega. + + + Nombre del encabezado + + + Valor del encabezado + + + Último código de error + + + Última recepción + + + Visto por última vez + + + Último uso + + + Última posición válida + + + Carga útil JSON + + + Nunca + + + No se han emitido credenciales para esta vinculación. + + + No hay vinculaciones de rastreo por hardware configuradas para esta unidad. + + + Guardar credencial de rastreo + + + Esta credencial se muestra una sola vez. Copie el punto de conexión y el secreto en la configuración del rastreador antes de salir de esta página. + + + Protocolo + + + Validar JSON de prueba + + + La carga útil JSON está mal formada, contiene campos duplicados o supera el límite de anidamiento. + + + Cada posición requiere un eventId no vacío y valores de latitud y longitud válidos. + + + Un lote debe contener al menos un objeto de posición JSON. + + + Ingrese una carga útil JSON para validar. + + + El analizador de vista previa aceptó {0} posición(es). No se puso nada en cola ni se almacenó. + + + La carga útil JSON supera el límite de tamaño de solicitud configurado. + + + El lote JSON supera el límite de posiciones configurado. + + + Revocar + + + ¿Revocar esta credencial? El remitente ya no podrá usarla. + + + Comportamiento de reintento: + + + Revocada + + + Rotar + + + Guardar vinculación de rastreo + + + Enviar JSON de prueba + + + Valide una carga útil JSON genérica de Resgrid sin autenticarla, ponerla en cola ni almacenarla. Esta vista previa solo está disponible para los administradores del departamento fuera de producción. + + + Instrucciones de configuración + + + Identificador secundario + + + Seleccione un perfil de rastreo certificado + + + Seleccione un perfil de rastreo certificado. + + + Prioridad de la fuente + + + Acciones de la vinculación de rastreo + + + Vinculación de rastreo creada. Genere una credencial para terminar la configuración. + + + Vinculación de rastreo quitada. + + + Vinculación de rastreo deshabilitada y sus credenciales revocadas. + + + Vinculación de rastreo actualizada. + + + Credencial de rastreo revocada. + + + Nombre para mostrar + + + Habilitado + + + Se requiere un identificador de dispositivo para el perfil seleccionado. + + + Perfil de rastreo + + + Estado del rastreo + + + Transporte + + + Este perfil de rastreo no admite el modo de autenticación seleccionado. + + + Ver estado del rastreo + + + Token de portador (Bearer) + + + Autenticación Basic + + + Encabezado personalizado + + + URL con token en la ruta + + + HTTPS nativo + + + HTTPS administrado (JSON) + + + TCP/UDP nativo + + + Pasarela de protocolo + + + Nunca visto + + + En línea + + + Sin datos recientes + + + Error + + + Deshabilitado + + + Candidato + + + Verificado con datos de ejemplo + + + Verificado en hardware + + + Certificado + + + Obsoleto + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx index c2035d01d..4c9b5c2f8 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.fr.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Ajouter un journal - Add Role + Ajouter un rôle - Add Unit Log + Ajouter un journal d'unité - Are you sure you want to permanently delete all statuses for this unit? + Voulez-vous vraiment supprimer définitivement tous les statuts de cette unité ? - Clear out all Statuses For Unit + Effacer tous les statuts de l'unité - Delete All + Tout supprimer - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + AVERTISSEMENT : cette action supprimera définitivement cette unité. Voulez-vous vraiment supprimer l’unité - Edit Unit + Modifier l'unité - Events for + Événements pour - Generate Report + Générer un rapport - Log Body (Narrative) + Corps du journal (récit) - A Narrative for the unit log + Un récit pour le journal de l'unité Journaux - New Unit + Nouvelle unité - Not Occupied + Non occupé - No Un-Grouped Units + Aucune unité non groupée - No Units in Department + Aucune unité dans le département - No Units in this Group + Aucune unité dans ce groupe - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + Pour définir l'effectif des unités, vous devez avoir créé des unités et des rôles (p. ex. conducteur, opérateur, etc.) au sein de ces unités. - Remove this role + Supprimer ce rôle - Role Name + Nom du rôle - Role Name + Nom du rôle - Select Unit + Sélectionner une unité - Set Status for Units + Définir le statut des unités - Set Status + Définir le statut - Set Status for Selected Units + Définir le statut des unités sélectionnées - Set Unit Status + Définir le statut de l'unité - You have selected a unit, only other units with the same custom (or default) states can be selected. + Vous avez sélectionné une unité : seules les autres unités ayant les mêmes statuts personnalisés (ou par défaut) peuvent être sélectionnées. - Unit Staffing + Effectifs des unités - Unit Staffing + Effectifs des unités - View Events + Voir les événements - View Unit Events + Voir les événements de l'unité - View Unit Logs + Voir les journaux de l'unité - Yes I'm sure + Oui, je confirme - You can add a new Type + Vous pouvez ajouter un nouveau type - here + ici Rôle du personnel requis @@ -182,4 +173,280 @@ Requis ? + + Ajouter une liaison de suivi + + + Réseaux sources autorisés + + + Plages CIDR IPv4 ou IPv6 facultatives, séparées par des virgules, autorisées à envoyer des positions pour ce traceur. + + + Mode d'authentification + + + Nom d'utilisateur pour l'authentification Basic + + + Un nom d'utilisateur pour l'authentification Basic est requis. + + + Statut de certification + + + Copier + + + Créer des informations d'identification + + + Préfixe d'identification + + + J'ai enregistré ces informations d'identification + + + Informations d'identification + + + Jeton d'identification + + + Un nom d'en-tête personnalisé est requis. + + + Supprimer cette liaison de suivi ? Ses informations d'identification seront révoquées et elle n'acceptera plus de positions. + + + Identifiant de l'appareil + + + Utilisez l'identifiant émis par l'appareil ou le service de relais. Il est normalisé avant l'enregistrement. + + + Désactiver le suivi + + + Désactiver cette liaison et révoquer toutes ses informations d'identification ? + + + Modifier la liaison de suivi + + + Point de terminaison HTTPS + + + Expire le + + + Version du micrologiciel + + + Suivi GPS matériel + + + Liez un traceur matériel ou un service de relais à cette unité, émettez des informations d'identification et surveillez l'état de la transmission. + + + Nom de l'en-tête + + + Valeur de l'en-tête + + + Dernier code d'erreur + + + Dernière réception + + + Dernière activité + + + Dernière utilisation + + + Dernière position GPS valide + + + Charge utile JSON + + + Jamais + + + Aucune information d'identification n'a été émise pour cette liaison. + + + Aucune liaison de suivi matériel n'est configurée pour cette unité. + + + Enregistrer les informations d'identification de suivi + + + Ces informations d'identification ne sont affichées qu'une seule fois. Copiez le point de terminaison et le secret dans la configuration du traceur avant de quitter cette page. + + + Protocole + + + Valider le JSON de test + + + La charge utile JSON est mal formée, contient des champs en double ou dépasse la limite d'imbrication. + + + Chaque position nécessite un eventId non vide ainsi que des valeurs de latitude et de longitude valides. + + + Un lot doit contenir au moins un objet de position JSON. + + + Saisissez une charge utile JSON à valider. + + + L'analyseur d'aperçu a accepté {0} position(s). Rien n'a été mis en file d'attente ni enregistré. + + + La charge utile JSON dépasse la taille de requête maximale configurée. + + + Le lot JSON dépasse la limite de positions configurée. + + + Révoquer + + + Révoquer ces informations d'identification ? L'expéditeur ne pourra plus les utiliser. + + + Comportement de nouvelle tentative : + + + Révoquées + + + Renouveler + + + Enregistrer la liaison de suivi + + + Envoyer un JSON de test + + + Validez une charge utile JSON Resgrid générique sans authentification, mise en file d'attente ni enregistrement. Cet aperçu n'est disponible que pour les administrateurs du département, hors environnement de production. + + + Instructions de configuration + + + Identifiant secondaire + + + Sélectionnez un profil de suivi certifié + + + Sélectionnez un profil de suivi certifié. + + + Priorité de la source + + + Actions sur la liaison de suivi + + + Liaison de suivi créée. Générez des informations d'identification pour terminer la configuration. + + + Liaison de suivi supprimée. + + + Liaison de suivi désactivée et ses informations d'identification révoquées. + + + Liaison de suivi mise à jour. + + + Informations d'identification de suivi révoquées. + + + Nom d'affichage + + + Activé + + + Un identifiant d'appareil est requis pour le profil sélectionné. + + + Profil de suivi + + + Statut du suivi + + + Transport + + + Le mode d'authentification sélectionné n'est pas pris en charge par ce profil de suivi. + + + Voir le statut du suivi + + + Jeton Bearer + + + Authentification Basic + + + En-tête personnalisé + + + URL avec jeton dans le chemin + + + HTTPS natif + + + HTTPS géré (JSON) + + + TCP/UDP natif + + + Passerelle de protocole + + + Aucune activité + + + En ligne + + + Sans données récentes + + + Erreur + + + Désactivé + + + Candidat + + + Vérifié avec des données d'exemple + + + Vérifié sur matériel + + + Certifié + + + Obsolète + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx index 72a5fc5d1..73573e3b9 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.it.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Aggiungi log - Add Role + Aggiungi ruolo - Add Unit Log + Aggiungi log dell'unità - Are you sure you want to permanently delete all statuses for this unit? + Vuoi davvero eliminare definitivamente tutti gli stati di questa unità? - Clear out all Statuses For Unit + Cancella tutti gli stati dell'unità - Delete All + Elimina tutto - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + ATTENZIONE: questa unità verrà eliminata definitivamente. Vuoi davvero eliminare l’unità - Edit Unit + Modifica unità - Events for + Eventi per - Generate Report + Genera rapporto - Log Body (Narrative) + Testo del log (narrativa) - A Narrative for the unit log + Una narrativa per il log dell'unità - Registri + Log - New Unit + Nuova unità - Not Occupied + Non occupato - No Un-Grouped Units + Nessuna unità non raggruppata - No Units in Department + Nessuna unità nel dipartimento - No Units in this Group + Nessuna unità in questo gruppo - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + Per assegnare il personale alle unità devi aver creato le unità e, al loro interno, i ruoli (ad es. autista, operatore, ecc.). - Remove this role + Rimuovi questo ruolo - Role Name + Nome del ruolo - Role Name + Nome del ruolo - Select Unit + Seleziona unità - Set Status for Units + Imposta lo stato delle unità - Set Status + Imposta stato - Set Status for Selected Units + Imposta lo stato delle unità selezionate - Set Unit Status + Imposta lo stato dell'unità - You have selected a unit, only other units with the same custom (or default) states can be selected. + Hai selezionato un'unità: puoi selezionare solo altre unità con gli stessi stati personalizzati (o predefiniti). - Unit Staffing + Personale delle unità - Unit Staffing + Personale delle unità - View Events + Visualizza eventi - View Unit Events + Visualizza gli eventi dell'unità - View Unit Logs + Visualizza i log dell'unità - Yes I'm sure + Sì, confermo - You can add a new Type + Puoi aggiungere un nuovo tipo - here + qui Ruolo del personale richiesto @@ -182,4 +173,280 @@ Richiesto? + + Aggiungi collegamento di tracciamento + + + Reti di origine consentite + + + Intervalli CIDR IPv4 o IPv6 facoltativi, separati da virgole, autorizzati a inviare posizioni per questo tracker. + + + Modalità di autenticazione + + + Nome utente per l'autenticazione Basic + + + È necessario un nome utente per l'autenticazione Basic. + + + Stato della certificazione + + + Copia + + + Crea credenziale + + + Prefisso della credenziale + + + Ho salvato questa credenziale + + + Credenziali + + + Token della credenziale + + + È necessario il nome di un'intestazione personalizzata. + + + Rimuovere questo collegamento di tracciamento? Le sue credenziali verranno revocate e smetterà di accettare posizioni. + + + Identificatore del dispositivo + + + Usa l'identificatore inviato dal dispositivo o dal servizio di inoltro. Viene normalizzato prima di essere memorizzato. + + + Disattiva tracciamento + + + Disattivare questo collegamento e revocare tutte le sue credenziali? + + + Modifica collegamento di tracciamento + + + Endpoint HTTPS + + + Scadenza + + + Versione firmware + + + Tracciamento GPS hardware + + + Collega un tracker hardware o un servizio di inoltro a questa unità, emetti le credenziali e monitora lo stato della consegna. + + + Nome dell'intestazione + + + Valore dell'intestazione + + + Ultimo codice di errore + + + Ultima ricezione + + + Ultimo contatto + + + Ultimo utilizzo + + + Ultimo fix valido + + + Payload JSON + + + Mai + + + Non è stata emessa alcuna credenziale per questo collegamento. + + + Nessun collegamento di tracciamento hardware configurato per questa unità. + + + Salva la credenziale di tracciamento + + + Questa credenziale viene mostrata una sola volta. Copia l'endpoint e il segreto nella configurazione del tracker prima di lasciare questa pagina. + + + Protocollo + + + Convalida JSON di prova + + + Il payload JSON non è valido, contiene campi duplicati o supera il limite di annidamento. + + + Ogni posizione richiede un eventId non vuoto e valori di latitudine e longitudine validi. + + + Un batch deve contenere almeno un oggetto posizione JSON. + + + Inserisci un payload JSON da convalidare. + + + Il parser di anteprima ha accettato {0} posizione/i. Nulla è stato messo in coda o memorizzato. + + + Il payload JSON supera il limite di dimensione della richiesta configurato. + + + Il batch JSON supera il limite di posizioni configurato. + + + Revoca + + + Revocare questa credenziale? Il mittente non potrà più usarla. + + + Comportamento previsto per i nuovi tentativi: + + + Revocata + + + Ruota + + + Salva collegamento di tracciamento + + + Invia JSON di prova + + + Convalida un payload JSON Resgrid generico senza autenticarlo, metterlo in coda o memorizzarlo. Questa anteprima è disponibile solo per gli amministratori del dipartimento al di fuori dell'ambiente di produzione. + + + Istruzioni di configurazione + + + Identificatore secondario + + + Seleziona un profilo di tracciamento certificato + + + Seleziona un profilo di tracciamento certificato. + + + Priorità dell'origine + + + Azioni sul collegamento di tracciamento + + + Collegamento di tracciamento creato. Genera una credenziale per completare la configurazione. + + + Collegamento di tracciamento rimosso. + + + Collegamento di tracciamento disattivato e relative credenziali revocate. + + + Collegamento di tracciamento aggiornato. + + + Credenziale di tracciamento revocata. + + + Nome visualizzato + + + Abilitato + + + Per il profilo selezionato è necessario un identificatore del dispositivo. + + + Profilo di tracciamento + + + Stato del tracciamento + + + Trasporto + + + La modalità di autenticazione selezionata non è supportata da questo profilo di tracciamento. + + + Visualizza stato del tracciamento + + + Token Bearer + + + Autenticazione Basic + + + Intestazione personalizzata + + + URL con token nel percorso + + + HTTPS nativo + + + HTTPS gestito (JSON) + + + TCP/UDP nativo + + + Gateway di protocollo + + + Nessun contatto + + + Online + + + Nessun dato recente + + + Errore + + + Disabilitato + + + Candidato + + + Verificato con dati di esempio + + + Verificato su hardware + + + Certificato + + + Obsoleto + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx index 6e2e851a1..ca5a8e653 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.pl.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Dodaj wpis - Add Role + Dodaj rolę - Add Unit Log + Dodaj wpis do dziennika jednostki - Are you sure you want to permanently delete all statuses for this unit? + Czy na pewno chcesz trwale usunąć wszystkie statusy tej jednostki? - Clear out all Statuses For Unit + Wyczyść wszystkie statusy jednostki - Delete All + Usuń wszystko - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + OSTRZEŻENIE: Ta operacja trwale usunie tę jednostkę. Czy na pewno chcesz usunąć jednostkę - Edit Unit + Edytuj jednostkę - Events for + Zdarzenia dla - Generate Report + Wygeneruj raport - Log Body (Narrative) + Treść wpisu (opis) - A Narrative for the unit log + Opis do dziennika jednostki Dzienniki - New Unit + Nowa jednostka - Not Occupied + Nieobsadzone - No Un-Grouped Units + Brak niezgrupowanych jednostek - No Units in Department + Brak jednostek w dziale - No Units in this Group + Brak jednostek w tej grupie - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + Aby ustawić obsadę jednostek, musisz mieć utworzone jednostki oraz role (np. kierowca, operator itp.) w tych jednostkach. - Remove this role + Usuń tę rolę - Role Name + Nazwa roli - Role Name + Nazwa roli - Select Unit + Wybierz jednostkę - Set Status for Units + Ustaw status jednostek - Set Status + Ustaw status - Set Status for Selected Units + Ustaw status wybranych jednostek - Set Unit Status + Ustaw status jednostki - You have selected a unit, only other units with the same custom (or default) states can be selected. + Wybrano jednostkę – możesz wybrać tylko inne jednostki z tymi samymi statusami niestandardowymi (lub domyślnymi). - Unit Staffing + Obsada jednostek - Unit Staffing + Obsada jednostek - View Events + Wyświetl zdarzenia - View Unit Events + Wyświetl zdarzenia jednostki - View Unit Logs + Wyświetl dzienniki jednostki - Yes I'm sure + Tak, potwierdzam - You can add a new Type + Nowy typ możesz dodać - here + tutaj Wymagana rola personelu @@ -182,4 +173,280 @@ Wymagane? + + Dodaj powiązanie śledzenia + + + Dozwolone sieci źródłowe + + + Opcjonalne zakresy CIDR IPv4 lub IPv6 oddzielone przecinkami, z których można wysyłać pozycje dla tego lokalizatora. + + + Tryb uwierzytelniania + + + Nazwa użytkownika do uwierzytelniania Basic + + + Wymagana jest nazwa użytkownika do uwierzytelniania Basic. + + + Status certyfikacji + + + Kopiuj + + + Utwórz poświadczenie + + + Prefiks poświadczenia + + + Mam już zapisane to poświadczenie + + + Poświadczenia + + + Token poświadczenia + + + Wymagana jest nazwa nagłówka niestandardowego. + + + Usunąć to powiązanie śledzenia? Jego poświadczenia zostaną cofnięte i przestanie ono przyjmować pozycje. + + + Identyfikator urządzenia + + + Użyj identyfikatora wysyłanego przez urządzenie lub usługę przekazującą. Przed zapisaniem jest on normalizowany. + + + Wyłącz śledzenie + + + Wyłączyć to powiązanie i cofnąć wszystkie jego poświadczenia? + + + Edytuj powiązanie śledzenia + + + Punkt końcowy HTTPS + + + Wygasa + + + Wersja oprogramowania układowego + + + Sprzętowe śledzenie GPS + + + Powiąż z tą jednostką sprzętowy lokalizator lub usługę przekazującą, wydawaj poświadczenia i monitoruj stan dostarczania danych. + + + Nazwa nagłówka + + + Wartość nagłówka + + + Ostatni kod błędu + + + Ostatnio odebrano + + + Ostatnio widziano + + + Ostatnie użycie + + + Ostatnia prawidłowa pozycja + + + Treść JSON + + + Nigdy + + + Dla tego powiązania nie wydano żadnych poświadczeń. + + + Dla tej jednostki nie skonfigurowano żadnych powiązań sprzętowego śledzenia. + + + Zapisz poświadczenie śledzenia + + + To poświadczenie jest wyświetlane tylko raz. Przed opuszczeniem tej strony skopiuj punkt końcowy i sekret do konfiguracji lokalizatora. + + + Protokół + + + Sprawdź testowy JSON + + + Treść JSON jest nieprawidłowo sformatowana, zawiera zduplikowane pola lub przekracza limit zagnieżdżenia. + + + Każda pozycja wymaga niepustego eventId oraz prawidłowych wartości szerokości i długości geograficznej. + + + Partia musi zawierać co najmniej jeden obiekt pozycji JSON. + + + Wpisz treść JSON do sprawdzenia. + + + Parser podglądu zaakceptował pozycje: {0}. Nic nie zostało dodane do kolejki ani zapisane. + + + Treść JSON przekracza skonfigurowany limit rozmiaru żądania. + + + Partia JSON przekracza skonfigurowany limit pozycji. + + + Cofnij + + + Cofnąć to poświadczenie? Nadawca nie będzie już mógł go używać. + + + Oczekiwane ponawianie: + + + Cofnięte + + + Zamień + + + Zapisz powiązanie śledzenia + + + Wyślij testowy JSON + + + Sprawdź ogólną treść JSON Resgrid bez uwierzytelniania, dodawania do kolejki ani zapisywania. Ten podgląd jest dostępny tylko dla administratorów działu poza środowiskiem produkcyjnym. + + + Instrukcje konfiguracji + + + Identyfikator dodatkowy + + + Wybierz certyfikowany profil śledzenia + + + Wybierz certyfikowany profil śledzenia. + + + Priorytet źródła + + + Akcje powiązania śledzenia + + + Utworzono powiązanie śledzenia. Wygeneruj poświadczenie, aby dokończyć konfigurację. + + + Usunięto powiązanie śledzenia. + + + Wyłączono powiązanie śledzenia i cofnięto jego poświadczenia. + + + Zaktualizowano powiązanie śledzenia. + + + Cofnięto poświadczenie śledzenia. + + + Nazwa wyświetlana + + + Włączone + + + Dla wybranego profilu wymagany jest identyfikator urządzenia. + + + Profil śledzenia + + + Status śledzenia + + + Transport + + + Wybrany tryb uwierzytelniania nie jest obsługiwany przez ten profil śledzenia. + + + Wyświetl status śledzenia + + + Token Bearer + + + Uwierzytelnianie Basic + + + Nagłówek niestandardowy + + + Adres URL z tokenem w ścieżce + + + Natywny HTTPS + + + Zarządzany HTTPS (JSON) + + + Natywny TCP/UDP + + + Brama protokołu + + + Nigdy nie widziano + + + Online + + + Brak aktualnych danych + + + Błąd + + + Wyłączone + + + Kandydat + + + Zweryfikowany na danych przykładowych + + + Zweryfikowany na sprzęcie + + + Certyfikowany + + + Przestarzały + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.resx deleted file mode 100644 index 972a6edb4..000000000 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.resx +++ /dev/null @@ -1,237 +0,0 @@ - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Add tracking binding - - - Allowed source networks - - - Optional comma-separated IPv4 or IPv6 CIDR ranges permitted to send positions for this tracker. - - - Authentication mode - - - Basic authentication username - - - A Basic authentication username is required. - - - Certification status - - - Copy - - - Create credential - - - Credential prefix - - - I have saved this credential - - - Credentials - - - Credential token - - - A custom header name is required. - - - Remove this tracking binding? Its credentials will be revoked and it will stop accepting positions. - - - Device identifier - - - Use the identifier emitted by the device or forwarding service. It is normalized before storage. - - - Disable tracking - - - Disable this binding and revoke all of its credentials? - - - Edit tracking binding - - - HTTPS endpoint - - - Expires - - - Firmware version - - - Hardware GPS tracking - - - Bind a hardware tracker or forwarding service to this Unit, issue credentials, and monitor delivery health. - - - Header name - - - Header value - - - Last error code - - - Last received - - - Last seen - - - Last used - - - Last valid fix - - - JSON payload - - - Never - - - No credentials have been issued for this binding. - - - No hardware tracking bindings are configured for this Unit. - - - Save tracking credential - - - This credential is shown only once. Copy the endpoint and secret into the tracker configuration before leaving this page. - - - Protocol - - - Validate test JSON - - - The JSON payload is malformed, contains duplicate fields, or exceeds the nesting limit. - - - Each position requires a non-empty eventId and valid latitude and longitude values. - - - A batch must contain at least one JSON position object. - - - Enter a JSON payload to validate. - - - The preview parser accepted {0} position(s). Nothing was queued or stored. - - - The JSON payload exceeds the configured request size limit. - - - The JSON batch exceeds the configured position limit. - - - Revoke - - - Revoke this credential? The sender will no longer be able to use it. - - - Retry expectation: - - - Revoked - - - Rotate - - - Save tracking binding - - - Send test JSON - - - Validate a generic Resgrid JSON payload without authenticating, queueing, or storing it. This preview is available only to department administrators outside production. - - - Setup instructions - - - Secondary identifier - - - Select a certified tracking profile - - - Select a certified tracking profile. - - - Source priority - - - Tracking binding actions - - - Tracking binding created. Generate a credential to finish setup. - - - Tracking binding removed. - - - Tracking binding disabled and its credentials revoked. - - - Tracking binding updated. - - - Tracking credential revoked. - - - Display name - - - Enabled - - - A device identifier is required for the selected profile. - - - Tracking profile - - - Tracking status - - - Transport - - - The selected authentication mode is not supported by this tracking profile. - - - View tracking status - - diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx index ea011db4c..9b57c64d7 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.sv.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Lägg till logg - Add Role + Lägg till roll - Add Unit Log + Lägg till enhetslogg - Are you sure you want to permanently delete all statuses for this unit? + Vill du verkligen ta bort alla statusar för den här enheten permanent? - Clear out all Statuses For Unit + Rensa alla statusar för enheten - Delete All + Ta bort alla - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + VARNING: Detta tar bort enheten permanent. Vill du verkligen ta bort enheten - Edit Unit + Redigera enhet - Events for + Händelser för - Generate Report + Generera rapport - Log Body (Narrative) + Loggtext (berättelse) - A Narrative for the unit log + En berättelse för enhetsloggen Loggar - New Unit + Ny enhet - Not Occupied + Ej tillsatt - No Un-Grouped Units + Inga ogrupperade enheter - No Units in Department + Inga enheter i avdelningen - No Units in this Group + Inga enheter i den här gruppen - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + För att ange bemanning för enheter måste du ha skapat enheter och roller (t.ex. förare, operatör osv.) i dessa enheter. - Remove this role + Ta bort den här rollen - Role Name + Rollnamn - Role Name + Rollnamn - Select Unit + Välj enhet - Set Status for Units + Ange status för enheter - Set Status + Ange status - Set Status for Selected Units + Ange status för valda enheter - Set Unit Status + Ange enhetsstatus - You have selected a unit, only other units with the same custom (or default) states can be selected. + Du har valt en enhet. Endast andra enheter med samma anpassade (eller standard-) statusar kan väljas. - Unit Staffing + Enhetsbemanning - Unit Staffing + Enhetsbemanning - View Events + Visa händelser - View Unit Events + Visa enhetshändelser - View Unit Logs + Visa enhetsloggar - Yes I'm sure + Ja, jag är säker - You can add a new Type + Du kan lägga till en ny typ - here + här Obligatorisk personalroll @@ -182,4 +173,280 @@ Krävs? + + Lägg till spårningskoppling + + + Tillåtna källnätverk + + + Valfria CIDR-intervall för IPv4 eller IPv6, avgränsade med kommatecken, som får skicka positioner för den här spåraren. + + + Autentiseringsläge + + + Användarnamn för Basic-autentisering + + + Ett användarnamn för Basic-autentisering krävs. + + + Certifieringsstatus + + + Kopiera + + + Skapa autentiseringsuppgift + + + Prefix för autentiseringsuppgift + + + Jag har sparat autentiseringsuppgiften + + + Autentiseringsuppgifter + + + Token för autentiseringsuppgift + + + Ett namn på den anpassade rubriken krävs. + + + Ta bort spårningskopplingen? Dess autentiseringsuppgifter återkallas och den slutar ta emot positioner. + + + Spårarens identifierare + + + Använd den identifierare som spåraren eller vidarebefordringstjänsten skickar. Den normaliseras innan den lagras. + + + Inaktivera spårning + + + Inaktivera kopplingen och återkalla alla dess autentiseringsuppgifter? + + + Redigera spårningskoppling + + + HTTPS-slutpunkt + + + Upphör + + + Firmwareversion + + + GPS-spårning med hårdvara + + + Koppla en hårdvaruspårare eller vidarebefordringstjänst till den här enheten, utfärda autentiseringsuppgifter och övervaka leveransstatus. + + + Rubriknamn + + + Rubrikvärde + + + Senaste felkod + + + Senast mottagen + + + Senast sedd + + + Senast använd + + + Senaste giltiga position + + + JSON-nyttolast + + + Aldrig + + + Inga autentiseringsuppgifter har utfärdats för den här kopplingen. + + + Inga spårningskopplingar för hårdvara är konfigurerade för den här enheten. + + + Spara autentiseringsuppgiften för spårning + + + Autentiseringsuppgiften visas bara en gång. Kopiera slutpunkten och hemligheten till spårarens konfiguration innan du lämnar sidan. + + + Protokoll + + + Validera test-JSON + + + JSON-nyttolasten är felformaterad, innehåller dubbletter av fält eller överskrider gränsen för nästlingsdjup. + + + Varje position kräver ett eventId som inte är tomt samt giltiga värden för latitud och longitud. + + + En batch måste innehålla minst ett JSON-positionsobjekt. + + + Ange en JSON-nyttolast att validera. + + + Förhandsgranskningen godkände {0} position(er). Inget köades eller lagrades. + + + JSON-nyttolasten överskrider den konfigurerade storleksgränsen för begäranden. + + + JSON-batchen överskrider den konfigurerade gränsen för antal positioner. + + + Återkalla + + + Återkalla autentiseringsuppgiften? Avsändaren kommer inte längre att kunna använda den. + + + Förväntade återförsök: + + + Återkallad + + + Rotera + + + Spara spårningskoppling + + + Skicka test-JSON + + + Validera en generisk Resgrid-JSON-nyttolast utan att autentisera, köa eller lagra den. Förhandsgranskningen är endast tillgänglig för avdelningsadministratörer utanför produktionsmiljön. + + + Installationsanvisningar + + + Sekundär identifierare + + + Välj en certifierad spårningsprofil + + + Välj en certifierad spårningsprofil. + + + Källprioritet + + + Åtgärder för spårningskoppling + + + Spårningskopplingen har skapats. Skapa en autentiseringsuppgift för att slutföra konfigurationen. + + + Spårningskopplingen har tagits bort. + + + Spårningskopplingen har inaktiverats och dess autentiseringsuppgifter har återkallats. + + + Spårningskopplingen har uppdaterats. + + + Autentiseringsuppgiften för spårning har återkallats. + + + Visningsnamn + + + Aktiverad + + + Spårarens identifierare krävs för den valda profilen. + + + Spårningsprofil + + + Spårningsstatus + + + Transport + + + Det valda autentiseringsläget stöds inte av den här spårningsprofilen. + + + Visa spårningsstatus + + + Bearer-token + + + Basic-autentisering + + + Anpassad rubrik + + + URL med token i sökvägen + + + Direkt HTTPS + + + Hanterad HTTPS (JSON) + + + Direkt TCP/UDP + + + Protokollgateway + + + Aldrig sedd + + + Online + + + Inga aktuella data + + + Fel + + + Inaktiverad + + + Kandidat + + + Verifierad med exempeldata + + + Verifierad på hårdvara + + + Certifierad + + + Utfasad + diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx index 6b755c2ed..1b3ceed0b 100644 --- a/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Units/Units.uk.resx @@ -59,122 +59,113 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - - Add Log + Додати запис - Add Role + Додати роль - Add Unit Log + Додати запис до журналу одиниці - Are you sure you want to permanently delete all statuses for this unit? + Ви дійсно бажаєте остаточно видалити всі статуси цієї одиниці? - Clear out all Statuses For Unit + Очистити всі статуси одиниці - Delete All + Видалити все - WARNING: This will permanently delete this unit. Are you sure you want to delete the unit + ПОПЕРЕДЖЕННЯ: цю одиницю буде видалено остаточно. Ви дійсно бажаєте видалити одиницю - Edit Unit + Редагувати одиницю - Events for + Події для - Generate Report + Сформувати звіт - Log Body (Narrative) + Текст запису (опис) - A Narrative for the unit log + Опис для журналу одиниці Журнали - New Unit + Нова одиниця - Not Occupied + Не зайнято - No Un-Grouped Units + Немає незгрупованих одиниць - No Units in Department + У підрозділі немає одиниць - No Units in this Group + У цій групі немає одиниць - To set staffing on units you need to have Units created and Roles (i.e. Driver, Operator, etc) created within those units. + Щоб налаштувати укомплектованість одиниць, потрібно створити одиниці та ролі (наприклад, водій, оператор тощо) у цих одиницях. - Remove this role + Видалити цю роль - Role Name + Назва ролі - Role Name + Назва ролі - Select Unit + Виберіть одиницю - Set Status for Units + Встановити статус одиниць - Set Status + Встановити статус - Set Status for Selected Units + Встановити статус вибраних одиниць - Set Unit Status + Встановити статус одиниці - You have selected a unit, only other units with the same custom (or default) states can be selected. + Ви вибрали одиницю, тому можна вибрати лише інші одиниці з тими самими власними (або стандартними) статусами. - Unit Staffing + Укомплектованість одиниць - Unit Staffing + Укомплектованість одиниць - View Events + Переглянути події - View Unit Events + Переглянути події одиниці - View Unit Logs + Переглянути журнали одиниці - Yes I'm sure + Так, підтверджую - You can add a new Type + Ви можете додати новий тип - here + тут Необхідна роль персоналу @@ -182,4 +173,280 @@ Обов'язково? + + Додати прив'язку відстеження + + + Дозволені мережі-джерела + + + Необов'язкові діапазони CIDR IPv4 або IPv6 через кому, з яких дозволено надсилати позиції для цього трекера. + + + Режим автентифікації + + + Ім'я користувача для автентифікації Basic + + + Потрібно вказати ім'я користувача для автентифікації Basic. + + + Статус сертифікації + + + Копіювати + + + Створити облікові дані + + + Префікс облікових даних + + + Облікові дані збережено + + + Облікові дані + + + Токен облікових даних + + + Потрібно вказати назву власного заголовка. + + + Видалити цю прив'язку відстеження? Її облікові дані буде відкликано, і вона перестане приймати позиції. + + + Ідентифікатор пристрою + + + Використовуйте ідентифікатор, який надсилає пристрій або служба пересилання. Перед збереженням його буде нормалізовано. + + + Вимкнути відстеження + + + Вимкнути цю прив'язку та відкликати всі її облікові дані? + + + Редагувати прив'язку відстеження + + + Кінцева точка HTTPS + + + Діє до + + + Версія прошивки + + + Апаратне GPS-відстеження + + + Прив'яжіть апаратний трекер або службу пересилання до цієї одиниці, видайте облікові дані та відстежуйте стан доставки. + + + Назва заголовка + + + Значення заголовка + + + Останній код помилки + + + Останнє отримання + + + Остання активність + + + Останнє використання + + + Остання дійсна позиція + + + Корисне навантаження JSON + + + Ніколи + + + Для цієї прив'язки ще не видано облікових даних. + + + Для цієї одиниці не налаштовано прив'язок апаратного відстеження. + + + Збережіть облікові дані відстеження + + + Ці облікові дані показуються лише один раз. Перш ніж залишити цю сторінку, скопіюйте кінцеву точку та секрет у конфігурацію трекера. + + + Протокол + + + Перевірити тестовий JSON + + + Корисне навантаження JSON має неправильний формат, містить повторювані поля або перевищує ліміт вкладеності. + + + Кожна позиція повинна мати непорожній eventId і дійсні значення широти та довготи. + + + Пакет повинен містити щонайменше один об'єкт позиції JSON. + + + Введіть корисне навантаження JSON для перевірки. + + + Парсер попереднього перегляду прийняв позицій: {0}. Нічого не поставлено в чергу й не збережено. + + + Корисне навантаження JSON перевищує налаштований ліміт розміру запиту. + + + Пакет JSON перевищує налаштований ліміт кількості позицій. + + + Відкликати + + + Відкликати ці облікові дані? Відправник більше не зможе ними користуватися. + + + Очікувані повторні спроби: + + + Відкликано + + + Замінити + + + Зберегти прив'язку відстеження + + + Надіслати тестовий JSON + + + Перевірте загальне корисне навантаження Resgrid JSON без автентифікації, постановки в чергу чи збереження. Цей попередній перегляд доступний лише адміністраторам підрозділу і лише поза робочим середовищем. + + + Інструкції з налаштування + + + Додатковий ідентифікатор + + + Виберіть сертифікований профіль відстеження + + + Виберіть сертифікований профіль відстеження. + + + Пріоритет джерела + + + Дії з прив'язкою відстеження + + + Прив'язку відстеження створено. Згенеруйте облікові дані, щоб завершити налаштування. + + + Прив'язку відстеження видалено. + + + Прив'язку відстеження вимкнено, а її облікові дані відкликано. + + + Прив'язку відстеження оновлено. + + + Облікові дані відстеження відкликано. + + + Відображувана назва + + + Увімкнено + + + Для вибраного профілю потрібно вказати ідентифікатор пристрою. + + + Профіль відстеження + + + Стан відстеження + + + Транспорт + + + Вибраний режим автентифікації не підтримується цим профілем відстеження. + + + Переглянути стан відстеження + + + Токен Bearer + + + Автентифікація Basic + + + Власний заголовок + + + URL-адреса з токеном у шляху + + + Нативний HTTPS + + + Керований HTTPS (JSON) + + + Нативний TCP/UDP + + + Шлюз протоколу + + + Ще не було активності + + + У мережі + + + Немає свіжих даних + + + Помилка + + + Вимкнено + + + Кандидат + + + Перевірено на тестових даних + + + Перевірено на обладнанні + + + Сертифіковано + + + Виведено з ужитку + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx index e59c8ff38..47fa0c6f0 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.de.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Benutzerdefinierte Felder + + + Benutzerdefinierte Felder für Abteilungsentitäten verwalten + + + Entitätstyp + + + Version + + + Felder + + + Status + + + Aktionen + + + Aktiv + + + Nicht konfiguriert + + + Konfigurieren + + + Bearbeiten + + + Löschen + + + Gesamte Feldkonfiguration für {0} löschen? Dies kann nicht rückgängig gemacht werden. + + + Felddefinitionen nach Entität + + + Felder konfigurieren — {0} + + + Felder konfigurieren — {0} + + + Felddefinitionen + + + Feld hinzufügen + + + Es wurden noch keine Felder hinzugefügt. Klicken Sie auf „Feld hinzufügen“, um das erste Feld zu definieren. + + + Technischer Name + + + Bezeichnung + + + Datentyp + + + Erforderlich + + + Aktiviert + + + Mobil + + + Berichte + + + Reihenfolge + + + Entfernen + + + Dieses Feld entfernen? + + + Definition speichern + + + Abbrechen + + + Felddetails + + + Interne Kennung (Buchstaben, Ziffern, Unterstriche; muss mit einem Buchstaben oder Unterstrich beginnen). Muss innerhalb dieses Entitätstyps eindeutig sein. + + + Lesbare Bezeichnung, die Benutzern angezeigt wird. + + + Beschreibung / Tooltip + + + Platzhaltertext + + + Standardwert + + + Gruppe / Abschnitt + + + Schreibgeschützt + + + Wenn aktiviert, wird das Feld angezeigt, kann aber von Benutzern nicht bearbeitet werden. + + + Validierungsregeln + + + Min. Länge + + + Max. Länge + + + Min. Wert + + + Max. Wert + + + Regex-Muster + + + Regex-Fehlermeldung + + + Optionen (Schlüssel=Bezeichnung, eine pro Zeile) + + + Geben Sie eine Option pro Zeile im Format Schlüssel=Bezeichnung ein, z. B. „yes=Ja“ oder „no=Nein“. + + + Feld hinzufügen + + + Schließen + + + Startseite + + + Neu + + + Benutzerdefinierte Felder + + + Einsatz + + + Personal + + + Einheit + + + Kontakt + + + Vorschau + + + Feldvorschau — {0} + + + Nur Vorschau + + + Dies ist ein Beispiel dafür, wie die Felder auf der Seite aussehen und dargestellt werden. Es werden keine Daten gespeichert. + + + Keine aktivierten Felder für die Vorschau. + + + Schließen + + + Felddefinition erfolgreich gespeichert. + + + Felddefinition gelöscht. + + + Bitte korrigieren Sie die folgenden Fehler vor dem Speichern. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx index ae5daa118..8e0728614 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.es.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Campos definidos por el usuario + + + Administre los campos personalizados de las entidades del departamento + + + Tipo de entidad + + + Versión + + + Campos + + + Estado + + + Acciones + + + Activo + + + Sin configurar + + + Configurar + + + Editar + + + Eliminar + + + ¿Eliminar toda la configuración de campos definidos por el usuario para {0}? Esta acción no se puede deshacer. + + + Definiciones de campos por entidad + + + Configurar campos — {0} + + + Configurar campos — {0} + + + Definiciones de campos + + + Agregar campo + + + Todavía no se han agregado campos. Haga clic en "Agregar campo" para definir el primero. + + + Nombre interno + + + Etiqueta + + + Tipo de dato + + + Requerido + + + Habilitado + + + Móvil + + + Informes + + + Orden + + + Quitar + + + ¿Quitar este campo? + + + Guardar definición + + + Cancelar + + + Detalles del campo + + + Identificador interno (letras, dígitos y guiones bajos; debe empezar con una letra o un guion bajo). Debe ser único dentro de este tipo de entidad. + + + Etiqueta legible que se muestra a los usuarios. + + + Descripción / Texto de ayuda + + + Texto de marcador de posición + + + Valor predeterminado + + + Grupo / Sección + + + Solo lectura + + + Cuando está habilitado, el campo se muestra, pero los usuarios no pueden editarlo. + + + Reglas de validación + + + Longitud mínima + + + Longitud máxima + + + Valor mínimo + + + Valor máximo + + + Patrón de expresión regular + + + Mensaje de error de la expresión regular + + + Opciones (Clave=Etiqueta, una por línea) + + + Ingrese una opción por línea con el formato Clave=Etiqueta, p. ej. "si=Sí" o "no=No". + + + Agregar campo + + + Cerrar + + + Inicio + + + Nuevo + + + Campos definidos por el usuario + + + Llamada + + + Personal + + + Unidad + + + Contacto + + + Vista previa + + + Vista previa de campos — {0} + + + Solo vista previa + + + Esta es una muestra de cómo se verán y se mostrarán los campos en la página. No se guardará ningún dato. + + + No hay campos habilitados para previsualizar. + + + Cerrar + + + La definición de campos se guardó correctamente. + + + Definición de campos eliminada. + + + Corrija los errores que se indican a continuación antes de guardar. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx index 935db8d6d..916baf823 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.fr.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Champs définis par l'utilisateur + + + Gérez les champs personnalisés des entités du département + + + Type d'entité + + + Version + + + Champs + + + Statut + + + Actions + + + Actif + + + Non configuré + + + Configurer + + + Modifier + + + Supprimer + + + Supprimer toute la configuration des champs personnalisés pour {0} ? Cette action est irréversible. + + + Définitions des champs personnalisés par entité + + + Configurer les champs — {0} + + + Configurer les champs — {0} + + + Définitions des champs + + + Ajouter un champ + + + Aucun champ n'a encore été ajouté. Cliquez sur « Ajouter un champ » pour définir le premier champ. + + + Nom technique + + + Libellé + + + Type de données + + + Requis + + + Activé + + + Mobile + + + Rapports + + + Ordre + + + Retirer + + + Retirer ce champ ? + + + Enregistrer la définition + + + Annuler + + + Détails du champ + + + Identifiant interne (lettres, chiffres, traits de soulignement ; doit commencer par une lettre ou un trait de soulignement). Doit être unique pour ce type d'entité. + + + Libellé lisible affiché aux utilisateurs. + + + Description / info-bulle + + + Texte indicatif + + + Valeur par défaut + + + Groupe / section + + + Lecture seule + + + Lorsque cette option est activée, le champ est affiché mais ne peut pas être modifié par les utilisateurs. + + + Règles de validation + + + Longueur min. + + + Longueur max. + + + Valeur min. + + + Valeur max. + + + Expression régulière + + + Message d'erreur de l'expression régulière + + + Options (Clé=Libellé, une par ligne) + + + Saisissez une option par ligne au format Clé=Libellé, p. ex. « yes=Oui » ou « no=Non ». + + + Ajouter le champ + + + Fermer + + + Accueil + + + Nouveau + + + Champs définis par l'utilisateur + + + Appel + + + Personnel + + + Unité + + + Contact + + + Aperçu + + + Aperçu des champs — {0} + + + Aperçu uniquement + + + Ceci est un exemple de l'apparence et du rendu des champs sur la page. Aucune donnée ne sera enregistrée. + + + Aucun champ activé à prévisualiser. + + + Fermer + + + Définition des champs enregistrée avec succès. + + + Définition des champs supprimée. + + + Veuillez corriger les erreurs ci-dessous avant d'enregistrer. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx index 1d1116ded..927d25f38 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.it.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Campi definiti dall'utente + + + Gestisci i campi personalizzati per le entità del dipartimento + + + Tipo di entità + + + Versione + + + Campi + + + Stato + + + Azioni + + + Attivo + + + Non configurato + + + Configura + + + Modifica + + + Elimina + + + Eliminare tutta la configurazione UDF per {0}? L'operazione non può essere annullata. + + + Definizioni UDF per entità + + + Configura campi — {0} + + + Configura campi — {0} + + + Definizioni dei campi + + + Aggiungi campo + + + Non è stato ancora aggiunto alcun campo. Fai clic su "Aggiungi campo" per definire il primo. + + + Nome tecnico + + + Etichetta + + + Tipo di dati + + + Obbligatorio + + + Abilitato + + + App mobile + + + Rapporti + + + Ordine + + + Rimuovi + + + Rimuovere questo campo? + + + Salva definizione + + + Annulla + + + Dettagli del campo + + + Identificatore interno (lettere, cifre e trattini bassi; deve iniziare con una lettera o un trattino basso). Deve essere univoco per questo tipo di entità. + + + Etichetta leggibile mostrata agli utenti. + + + Descrizione / suggerimento + + + Testo segnaposto + + + Valore predefinito + + + Gruppo / sezione + + + Sola lettura + + + Se abilitato, il campo viene mostrato ma gli utenti non possono modificarlo. + + + Regole di convalida + + + Lunghezza minima + + + Lunghezza massima + + + Valore minimo + + + Valore massimo + + + Espressione regolare + + + Messaggio di errore dell'espressione regolare + + + Opzioni (Chiave=Etichetta, una per riga) + + + Inserisci un'opzione per riga nel formato Chiave=Etichetta, ad es. "si=Sì" o "no=No". + + + Aggiungi campo + + + Chiudi + + + Home + + + Nuovo + + + Campi definiti dall'utente + + + Chiamata + + + Personale + + + Unità + + + Contatto + + + Anteprima + + + Anteprima campi — {0} + + + Solo anteprima + + + Questo è un esempio di come i campi appariranno e verranno visualizzati nella pagina. Nessun dato verrà salvato. + + + Nessun campo abilitato da mostrare in anteprima. + + + Chiudi + + + Definizione dei campi salvata. + + + Definizione dei campi eliminata. + + + Correggi gli errori indicati di seguito prima di salvare. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx index 496ff89cf..b8778c5a8 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.pl.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Pola zdefiniowane przez użytkownika + + + Zarządzaj polami niestandardowymi dla elementów działu + + + Typ elementu + + + Wersja + + + Pola + + + Status + + + Akcje + + + Aktywna + + + Nie skonfigurowano + + + Konfiguruj + + + Edytuj + + + Usuń + + + Usunąć całą konfigurację UDF dla {0}? Tej operacji nie można cofnąć. + + + Definicje UDF według typu elementu + + + Konfiguruj pola — {0} + + + Konfiguruj pola — {0} + + + Definicje pól + + + Dodaj pole + + + Nie dodano jeszcze żadnych pól. Kliknij „Dodaj pole”, aby zdefiniować pierwsze pole. + + + Nazwa systemowa + + + Etykieta + + + Typ danych + + + Wymagane + + + Włączone + + + Mobilne + + + Raporty + + + Kolejność + + + Usuń + + + Usunąć to pole? + + + Zapisz definicję + + + Anuluj + + + Szczegóły pola + + + Identyfikator wewnętrzny (litery, cyfry, podkreślenia; musi zaczynać się od litery lub podkreślenia). Musi być unikalny w obrębie tego typu elementu. + + + Czytelna etykieta wyświetlana użytkownikom. + + + Opis / podpowiedź + + + Tekst zastępczy + + + Wartość domyślna + + + Grupa / sekcja + + + Tylko do odczytu + + + Po włączeniu pole jest wyświetlane, ale użytkownicy nie mogą go edytować. + + + Reguły walidacji + + + Min. długość + + + Maks. długość + + + Min. wartość + + + Maks. wartość + + + Wzorzec wyrażenia regularnego + + + Komunikat błędu wyrażenia regularnego + + + Opcje (klucz=etykieta, po jednej w wierszu) + + + Wpisz po jednej opcji w wierszu w formacie klucz=etykieta, np. „tak=Tak” lub „nie=Nie”. + + + Dodaj pole + + + Zamknij + + + Strona główna + + + Nowa + + + Pola zdefiniowane przez użytkownika + + + Zgłoszenie + + + Personel + + + Jednostka + + + Kontakt + + + Podgląd + + + Podgląd pól — {0} + + + Tylko podgląd + + + To przykład tego, jak pola będą wyglądać i wyświetlać się na stronie. Żadne dane nie zostaną zapisane. + + + Brak włączonych pól do podglądu. + + + Zamknij + + + Definicja pól została zapisana. + + + Definicja pól została usunięta. + + + Popraw poniższe błędy przed zapisaniem. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx index 22eb86173..3126dbac8 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.sv.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Användardefinierade fält + + + Hantera anpassade fält för avdelningens entiteter + + + Entitetstyp + + + Version + + + Fält + + + Status + + + Åtgärder + + + Aktiv + + + Inte konfigurerad + + + Konfigurera + + + Redigera + + + Ta bort + + + Ta bort all UDF-konfiguration för {0}? Det går inte att ångra. + + + UDF-definitioner per entitet + + + Konfigurera fält — {0} + + + Konfigurera fält — {0} + + + Fältdefinitioner + + + Lägg till fält + + + Inga fält har lagts till ännu. Klicka på ”Lägg till fält” för att definiera det första fältet. + + + Maskinnamn + + + Etikett + + + Datatyp + + + Obligatoriskt + + + Aktiverat + + + Mobil + + + Rapporter + + + Ordning + + + Ta bort + + + Ta bort fältet? + + + Spara definition + + + Avbryt + + + Fältdetaljer + + + Intern identifierare (bokstäver, siffror och understreck; måste börja med en bokstav eller ett understreck). Måste vara unik inom den här entitetstypen. + + + Läsbar etikett som visas för användarna. + + + Beskrivning / verktygstips + + + Platshållartext + + + Standardvärde + + + Grupp / avsnitt + + + Skrivskyddat + + + När detta är aktiverat visas fältet, men användarna kan inte redigera det. + + + Valideringsregler + + + Minsta längd + + + Största längd + + + Minsta värde + + + Största värde + + + Regex-mönster + + + Felmeddelande för regex + + + Alternativ (nyckel=etikett, ett per rad) + + + Ange ett alternativ per rad i formatet nyckel=etikett, t.ex. ”yes=Ja” eller ”no=Nej”. + + + Lägg till fält + + + Stäng + + + Hem + + + Ny + + + Användardefinierade fält + + + Larm + + + Personal + + + Enhet + + + Kontakt + + + Förhandsgranska + + + Förhandsgranskning av fält — {0} + + + Endast förhandsgranskning + + + Det här är ett exempel på hur fälten ser ut och återges på sidan. Inga data sparas. + + + Det finns inga aktiverade fält att förhandsgranska. + + + Stäng + + + Fältdefinitionen har sparats. + + + Fältdefinitionen har tagits bort. + + + Rätta felen nedan innan du sparar. + diff --git a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx index eb131bf30..d634b571c 100644 --- a/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/UserDefinedFields/UserDefinedFields.uk.resx @@ -128,4 +128,205 @@ 42 CFR Part 2 + + Користувацькі поля + + + Керування власними полями для сутностей підрозділу + + + Тип сутності + + + Версія + + + Поля + + + Статус + + + Дії + + + Активне + + + Не налаштовано + + + Налаштувати + + + Редагувати + + + Видалити + + + Видалити всю конфігурацію користувацьких полів для {0}? Цю дію неможливо скасувати. + + + Визначення користувацьких полів за сутностями + + + Налаштування полів — {0} + + + Налаштування полів — {0} + + + Визначення полів + + + Додати поле + + + Поля ще не додано. Натисніть «Додати поле», щоб визначити перше поле. + + + Системна назва + + + Підпис + + + Тип даних + + + Обов'язкове + + + Увімкнено + + + Мобільні + + + Звіти + + + Порядок + + + Видалити + + + Видалити це поле? + + + Зберегти визначення + + + Скасувати + + + Параметри поля + + + Внутрішній ідентифікатор (літери, цифри, символи підкреслення; має починатися з літери або символу підкреслення). Має бути унікальним у межах цього типу сутності. + + + Зрозумілий підпис, який бачать користувачі. + + + Опис / підказка + + + Текст-заповнювач + + + Значення за замовчуванням + + + Група / розділ + + + Лише для читання + + + Якщо ввімкнено, поле відображається, але користувачі не можуть його редагувати. + + + Правила перевірки + + + Мін. довжина + + + Макс. довжина + + + Мін. значення + + + Макс. значення + + + Шаблон регулярного виразу + + + Повідомлення про помилку регулярного виразу + + + Варіанти (Ключ=Підпис, по одному на рядок) + + + Вводьте по одному варіанту на рядок у форматі Ключ=Підпис, наприклад «yes=Так» або «no=Ні». + + + Додати поле + + + Закрити + + + Головна + + + Нове + + + Користувацькі поля + + + Виклик + + + Персонал + + + Одиниця + + + Контакт + + + Попередній перегляд + + + Попередній перегляд полів — {0} + + + Лише попередній перегляд + + + Це зразок того, як поля виглядатимуть і відображатимуться на сторінці. Дані не буде збережено. + + + Немає увімкнених полів для попереднього перегляду. + + + Закрити + + + Визначення полів успішно збережено. + + + Визначення полів видалено. + + + Виправте наведені нижче помилки перед збереженням. + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx index 1d16b38be..a47d48eee 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.ar.resx @@ -725,4 +725,325 @@ عند إغلاق بلاغ، يرسل مستند MDM^T02 "Crisis Field Response" إلى محرك تكامل عبر HTTPS: PID-3 هو معرّف العميل في السجل الصحي، وMSH-10 مفتاح عدم التكرار، وOBX لكل حقل مخصص مسموح به. ينجح فقط عند تلقي إقرار AA. + + قالب المخرجات + + + الحقل {0} مطلوب. + + + يتطلب هذا النوع من الإجراءات بيانات اعتماد. + + + إرسال بريد إلكتروني + + + إرسال رسالة نصية + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + رفع عبر FTP + + + رفع عبر SFTP + + + رفع إلى S3 + + + رسالة Teams + + + رسالة Slack + + + رسالة Discord + + + رفع إلى Azure Blob + + + رفع إلى Box + + + رفع إلى Dropbox + + + مشروطة + + + لا يمكن تغيير نوع حدث التشغيل بعد الإنشاء. + + + إلى + + + عناوين بريد إلكتروني مفصولة بفواصل. + + + نسخة إلى + + + اختياري + + + الموضوع + + + إلى (رقم الهاتف) + + + بصيغة E.164. يتطلب بيانات اعتماد Twilio. + + + عنوان URL + + + المهلة (ثانية) + + + المسار البعيد + + + اسم الملف + + + اتركه فارغاً لاستخدام اسم يُنشأ تلقائياً. + + + مفتاح S3 (المسار) + + + المفتاح / المسار الكامل داخل الحاوية. + + + عنوان البطاقة + + + لون السمة + + + قيمة سداسية عشرية بدون # + + + القناة + + + اتركه فارغاً لاستخدام القناة الافتراضية المحددة في webhook. + + + اسم مستخدم Bot + + + الرمز التعبيري للأيقونة + + + عنوان URL للصورة الرمزية + + + اسم / مسار Blob + + + اتركه فارغاً لاستخدام مسار يُنشأ تلقائياً. + + + معرّف المجلد + + + "0" = المجلد الجذر. + + + المسار الهدف + + + وضع الكتابة + + + استبدال + + + إضافة (إعادة التسمية عند التعارض) + + + تحديث (يفشل إن لم يكن موجوداً) + + + بيانات الاعتماد + + + (لا شيء) + + + لم يتم العثور على بيانات اعتماد مطابقة لنوع الإجراء هذا. + + + أضف بيانات اعتماد الآن → + + + بيانات اعتماد SMTP المستخدمة لإرسال البريد الإلكتروني. + + + بيانات اعتماد Twilio لإرسال الرسائل النصية. + + + بيانات اعتماد اختيارية لمصادقة HTTP. + + + بيانات اعتماد خادم FTP. + + + بيانات اعتماد خادم SFTP. + + + بيانات اعتماد AWS S3 (مفتاح الوصول + الحاوية). + + + بيانات اعتماد Webhook الوارد لـ Microsoft Teams. + + + بيانات اعتماد Slack من نوع webhook أو رمز bot. + + + بيانات اعتماد Discord من نوع webhook أو رمز bot. + + + بيانات اعتماد Azure Blob Storage (سلسلة الاتصال). + + + بيانات اعتماد تطبيق Box (JWT). + + + بيانات اعتماد Dropbox (رمز الوصول). + + + — اختر بيانات الاعتماد — + + + (لا شيء / دون مصادقة) + + + تعبير الشرط + + + (اختياري) + + + اتركه فارغاً لتشغيل هذه الخطوة دائماً. عند تعيينه، لا تُشغَّل الخطوة إلا إذا كانت نتيجة تقييم الشرط {0}. يستخدم المتغيرات نفسها المتاحة في قالب المخرجات. + + + المنشئ المرئي + + + التعبير الخام + + + إضافة قاعدة + + + تُربط القواعد المتعددة بـ AND — يجب أن تتحقق جميعها. + + + اختبار الشرط + + + يحتوي على + + + لا يحتوي على + + + يساوي + + + لا يساوي + + + يبدأ بـ + + + ينتهي بـ + + + فارغ + + + غير فارغ + + + القيمة + + + التعبير الحالي معقد جداً بالنسبة إلى المنشئ المرئي. عدّله في علامة التبويب «التعبير الخام». + + + لا يوجد شرط — ستُشغَّل الخطوة دائماً. + + + جارٍ الاختبار… + + + نتيجة التقييم: "{0}" — ستُشغَّل الخطوة + + + نتيجة التقييم: "{0}" — سيتم تخطي الخطوة + + + الصياغة صحيحة (لا توجد حمولة نموذجية لتقييم الشرط عليها) + + + فشل طلب الاختبار. + + + استخدم صياغة قوالب Scriban. انقر على المتغيرات في اللوحة الجانبية لإدراجها. + + + انقر على متغير {0} لإدراجه. + + + انقر على متغير {0} لإدراج مقتطف حلقة {1}. + + + مفرد + + + مصفوفة + + + مجموعات البلاغ (مصفوفات) + + + انقر لإدراج حلقة {0}. متاحة لمشغّلات CallAdded وCallUpdated وCallClosed. + + + الحقول: {0} + + + الوحدة / الأفراد + + + فشل حفظ الخطوة. + + + فشل الحذف: {0} + + + مثلاً: بلاغ جديد: {0} + + + إشعار Resgrid + + + بوت Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx index 4c680afcb..2c8f79165 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.de.resx @@ -63,112 +63,112 @@ Workflows - Department Workflows + Abteilungs-Workflows - New Workflow + Neuer Workflow - Credentials + Zugangsdaten - Pending Runs + Ausstehende Ausführungen - No workflows have been created yet. + Es wurden noch keine Workflows erstellt. - Create your first workflow. + Erstellen Sie Ihren ersten Workflow. Name - Trigger Event + Auslöseereignis Status - Created + Erstellt Aktionen - Enabled + Aktiviert - Disabled + Deaktiviert Bearbeiten - Runs + Ausführungen - Health + Zustand Löschen - Delete workflow {0}? + Workflow {0} löschen? - New Workflow + Neuer Workflow - Create Workflow + Workflow erstellen Beschreibung - Max Retry Count + Maximale Anzahl Wiederholungen - Retry Backoff Base (seconds) + Basis-Wartezeit für Wiederholungen (Sekunden) - e.g. Notify Slack on New Call + z. B. Slack bei neuem Einsatz benachrichtigen - Optional description + Optionale Beschreibung Abbrechen - Edit Workflow + Workflow bearbeiten - Workflow Settings + Workflow-Einstellungen - Max Retries + Max. Wiederholungen - Backoff (s) + Wartezeit (s) - Save Changes + Änderungen speichern - Steps + Schritte - Add Step + Schritt hinzufügen - Action Type + Aktionstyp - Bestellung + Reihenfolge - Template (first 120 chars) + Vorlage (erste 120 Zeichen) Ja @@ -177,64 +177,64 @@ Nein - Are you sure you want to delete this step? This action cannot be undone. + Möchten Sie diesen Schritt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. - Delete Step + Schritt löschen - Template Variables + Vorlagenvariablen - Common variables (always available): + Allgemeine Variablen (immer verfügbar): - Call event variables: + Variablen für Einsatzereignisse: - Workflow Credentials + Workflow-Zugangsdaten - Stored Credentials + Gespeicherte Zugangsdaten - Add Credential + Zugangsdaten hinzufügen - No credentials configured. Add credentials to use with workflow action steps. + Keine Zugangsdaten konfiguriert. Fügen Sie Zugangsdaten hinzu, um sie in Aktionsschritten von Workflows zu verwenden. Typ - Secret Data + Geheime Daten - Delete credential {0}? + Zugangsdaten {0} löschen? - Please correct the following errors before saving. + Bitte korrigieren Sie vor dem Speichern die folgenden Fehler. - Add Credential + Zugangsdaten hinzufügen - Edit Credential + Zugangsdaten bearbeiten - Credential Details + Details der Zugangsdaten Typ - Save Credential + Zugangsdaten speichern - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + Vorhandene geheime Werte werden verschlüsselt gespeichert und können nicht angezeigt werden. Füllen Sie die folgenden Felder aus, um sie zu ersetzen, oder lassen Sie alle Felder leer, um die aktuellen geheimen Werte beizubehalten. - SMTP Settings + SMTP-Einstellungen Host @@ -249,31 +249,31 @@ Passwort - Use SSL/TLS + SSL/TLS verwenden - From Address + Absenderadresse - The email address that will appear in the "From" field of sent messages. + Die E-Mail-Adresse, die im Feld „Von“ gesendeter Nachrichten angezeigt wird. - Twilio Settings + Twilio-Einstellungen - Account SID + Konto-SID - Auth Token + Auth-Token - From Number + Absendernummer - E.164 format, e.g. +15551234567 + E.164-Format, z. B. +15551234567 - FTP Settings + FTP-Einstellungen Host @@ -288,10 +288,10 @@ Passwort - Use Passive Mode + Passiven Modus verwenden - SFTP Settings + SFTP-Einstellungen Host @@ -306,37 +306,37 @@ Passwort - Private Key (PEM) + Privater Schlüssel (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Geben Sie ein Passwort oder einen privaten Schlüssel an (oder beides, wenn der Schlüssel durch eine Passphrase geschützt ist). - AWS S3 Settings + AWS-S3-Einstellungen - Access Key ID + Zugriffsschlüssel-ID - Secret Access Key + Geheimer Zugriffsschlüssel Region - Bucket Name + Bucket-Name - HTTP Bearer Token + HTTP-Bearer-Token - Bearer Token + Bearer-Token - Sent as Authorization: Bearer <token> + Wird als Authorization: Bearer <token> gesendet - HTTP Basic Auth + HTTP-Basic-Authentifizierung Benutzername @@ -345,148 +345,148 @@ Passwort - HTTP API Key + HTTP-API-Schlüssel - Header Name + Header-Name - The HTTP header name that carries the API key. + Der Name des HTTP-Headers, der den API-Schlüssel enthält. - API Key + API-Schlüssel - Microsoft Teams Webhook + Webhook für Microsoft Teams - Incoming Webhook URL + URL des eingehenden Webhooks - Create an Incoming Webhook connector inside the desired Teams channel. + Erstellen Sie im gewünschten Teams-Kanal einen Connector vom Typ „Eingehender Webhook“. Slack - Incoming Webhook URL + URL des eingehenden Webhooks - Bot Token + Bot-Token - Provide a webhook URL, a bot token, or both. + Geben Sie eine Webhook-URL, ein Bot-Token oder beides an. Discord - Webhook URL + Webhook-URL - Bot Token + Bot-Token - Provide a webhook URL, a bot token, or both. + Geben Sie eine Webhook-URL, ein Bot-Token oder beides an. Azure Blob Storage - Connection String + Verbindungszeichenfolge - Container Name + Containername Box - Client ID + Client-ID - Client Secret + Geheimer Clientschlüssel - Enterprise ID + Enterprise-ID - Public Key ID + ID des öffentlichen Schlüssels - Private Key (PEM) + Privater Schlüssel (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + Der private RSA-Schlüssel aus der Konfiguration Ihrer Box-JWT-Anwendung (PEM-Format). - Private Key Passphrase + Passphrase des privaten Schlüssels - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Optionale Passphrase zum Entschlüsseln des privaten Schlüssels, falls dieser durch eine Passphrase geschützt ist. Dropbox - Refresh Token + Aktualisierungstoken - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Langlebiges OAuth2-Aktualisierungstoken aus dem OAuth2-Autorisierungsablauf von Dropbox. - App Key + App-Schlüssel - App Secret + App-Geheimnis - Pending / Running Workflows + Ausstehende / laufende Workflows - Active Runs + Aktive Ausführungen - Clear All Pending + Alle ausstehenden abbrechen - Cancel ALL pending runs for this department? + ALLE ausstehenden Ausführungen dieser Abteilung abbrechen? - No pending or running workflow runs. + Keine ausstehenden oder laufenden Workflow-Ausführungen. - Run ID + Ausführungs-ID Workflow - Queued + Eingereiht - Attempt + Versuch Abbrechen - Cancel this run? + Diese Ausführung abbrechen? - Workflow Runs + Workflow-Ausführungen - Run History + Ausführungsverlauf - No runs found for this workflow. + Für diesen Workflow wurden keine Ausführungen gefunden. - Started + Gestartet - Completed + Abgeschlossen Fehler @@ -498,46 +498,46 @@ Nächster - Workflow Health + Workflow-Zustand - No health data available for this workflow yet. + Für diesen Workflow sind noch keine Zustandsdaten verfügbar. - Last 24 Hours + Letzte 24 Stunden - Last 7 Days + Letzte 7 Tage - Last 30 Days + Letzte 30 Tage - Successful + Erfolgreich - Failed + Fehlgeschlagen - Total + Gesamt - Success rate + Erfolgsquote - Performance + Leistung - Avg Duration + Durchschn. Dauer - Last Run + Letzte Ausführung - Last Error + Letzter Fehler - View Runs + Ausführungen anzeigen Niemals @@ -726,4 +726,325 @@ Beim Abschluss eines Einsatzes wird ein MDM^T02-Dokument "Crisis Field Response" per HTTPS an eine Schnittstellen-Engine gesendet: PID-3 ist die Klienten-ID der Patientenakte, MSH-10 der Idempotenzschlüssel, ein OBX je freigegebenem benutzerdefiniertem Feld. Erfolgreich nur bei einer AA-Bestätigung. + + Ausgabevorlage + + + {0} ist erforderlich. + + + Für diesen Aktionstyp sind Zugangsdaten erforderlich. + + + E-Mail senden + + + SMS senden + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + FTP-Upload + + + SFTP-Upload + + + S3-Upload + + + Teams-Nachricht + + + Slack-Nachricht + + + Discord-Nachricht + + + Azure-Blob-Upload + + + Box-Upload + + + Dropbox-Upload + + + Bedingt + + + Der Typ des Auslöseereignisses kann nach dem Erstellen nicht mehr geändert werden. + + + An + + + Durch Kommas getrennte E-Mail-Adressen. + + + Cc + + + optional + + + Betreff + + + An (Telefonnummer) + + + E.164-Format. Erfordert Twilio-Zugangsdaten. + + + URL + + + Zeitlimit (Sekunden) + + + Serverpfad + + + Dateiname + + + Leer lassen, um den Namen automatisch zu erzeugen. + + + S3-Schlüssel (Pfad) + + + Vollständiger Schlüssel / Pfad im Bucket. + + + Kartentitel + + + Designfarbe + + + Hex-Wert ohne # + + + Kanal + + + Leer lassen, um den Standardkanal des Webhooks zu verwenden. + + + Bot-Benutzername + + + Symbol-Emoji + + + Avatar-URL + + + Blob-Name / Pfad + + + Leer lassen, um den Pfad automatisch zu erzeugen. + + + Ordner-ID + + + "0" = Stammordner. + + + Zielpfad + + + Schreibmodus + + + Überschreiben + + + Hinzufügen (bei Konflikt umbenennen) + + + Aktualisieren (Fehler, falls nicht vorhanden) + + + Zugangsdaten + + + (keine) + + + Für diesen Aktionstyp wurden keine passenden Zugangsdaten gefunden. + + + Jetzt hinzufügen → + + + SMTP-Zugangsdaten, über die E-Mails gesendet werden. + + + Twilio-Zugangsdaten für den SMS-Versand. + + + Optionale Zugangsdaten für die HTTP-Authentifizierung. + + + Zugangsdaten für den FTP-Server. + + + Zugangsdaten für den SFTP-Server. + + + AWS-S3-Zugangsdaten (Zugriffsschlüssel + Bucket). + + + Zugangsdaten für einen eingehenden Webhook von Microsoft Teams. + + + Slack-Zugangsdaten mit Webhook oder Bot-Token. + + + Discord-Zugangsdaten mit Webhook oder Bot-Token. + + + Zugangsdaten mit Verbindungszeichenfolge für Azure Blob Storage. + + + Zugangsdaten einer Box-Anwendung (JWT). + + + Dropbox-Zugangsdaten mit Zugriffstoken. + + + — Zugangsdaten auswählen — + + + (keine / nicht authentifiziert) + + + Bedingungsausdruck + + + (optional) + + + Lassen Sie das Feld leer, um diesen Schritt immer auszuführen. Ist eine Bedingung gesetzt, wird der Schritt nur ausgeführt, wenn sie zu {0} ausgewertet wird. Es stehen dieselben Variablen wie in der Ausgabevorlage zur Verfügung. + + + Visueller Editor + + + Roher Ausdruck + + + Regel hinzufügen + + + Mehrere Regeln werden mit AND verknüpft — alle müssen erfüllt sein. + + + Bedingung testen + + + enthält + + + enthält nicht + + + ist gleich + + + ist ungleich + + + beginnt mit + + + endet mit + + + ist leer + + + ist nicht leer + + + Wert + + + Der aktuelle Ausdruck ist für den visuellen Editor zu komplex. Bearbeiten Sie ihn auf der Registerkarte „Roher Ausdruck“. + + + Keine Bedingung — der Schritt wird immer ausgeführt. + + + Wird getestet… + + + Ausgewertet: "{0}" — Schritt wird ausgeführt + + + Ausgewertet: "{0}" — Schritt wird übersprungen + + + Syntax gültig (keine Beispieldaten zum Auswerten vorhanden) + + + Die Testanfrage ist fehlgeschlagen. + + + Verwenden Sie die Scriban-Vorlagensyntax. Klicken Sie im Bereich rechts auf Variablen, um sie einzufügen. + + + Klicken Sie auf eine Variable vom Typ {0}, um sie einzufügen. + + + Klicken Sie auf eine Variable vom Typ {0}, um einen Codeausschnitt mit einer {1}-Schleife einzufügen. + + + Skalar + + + Array + + + Einsatz-Sammlungen (Arrays) + + + Klicken Sie, um eine {0}-Schleife einzufügen. Verfügbar für die Auslöser CallAdded, CallUpdated, CallClosed. + + + Felder: {0} + + + Einheit / Personal + + + Der Schritt konnte nicht gespeichert werden. + + + Löschen fehlgeschlagen: {0} + + + z. B. Neuer Einsatz: {0} + + + Resgrid-Benachrichtigung + + + Resgrid-Bot + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx index 109e9c4a5..e2294b987 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.el.resx @@ -267,10 +267,10 @@ Ρυθμίσεις Twilio - Account SID + SID Λογαριασμού - Auth Token + Διακριτικό Πιστοποίησης Αριθμός Αποστολέα @@ -324,10 +324,10 @@ Ρυθμίσεις AWS S3 - Access Key ID + Αναγνωριστικό Κλειδιού Πρόσβασης - Secret Access Key + Μυστικό Κλειδί Πρόσβασης Περιοχή @@ -419,16 +419,16 @@ Box - Client ID + Αναγνωριστικό Πελάτη - Client Secret + Μυστικό Πελάτη - Enterprise ID + Αναγνωριστικό Επιχείρησης - Public Key ID + Αναγνωριστικό Δημόσιου Κλειδιού Ιδιωτικό Κλειδί (PEM) @@ -447,16 +447,16 @@ Dropbox - Refresh Token + Διακριτικό Ανανέωσης Διακριτικό ανανέωσης OAuth2 μακράς διάρκειας που λαμβάνεται από τη ροή εξουσιοδότησης OAuth2 του Dropbox. - App Key + Κλειδί Εφαρμογής - App Secret + Μυστικό Εφαρμογής @@ -749,5 +749,326 @@ Όταν κλείνει μια κλήση, στέλνει ένα έγγραφο MDM^T02 «Crisis Field Response» σε μηχανή διασύνδεσης μέσω HTTPS: το PID-3 είναι το αναγνωριστικό πελάτη του φακέλου υγείας, το MSH-10 το κλειδί idempotency, ένα OBX για κάθε εγκεκριμένο προσαρμοσμένο πεδίο. Επιτυγχάνει μόνο με επιβεβαίωση AA. + + Πρότυπο Εξόδου + + + Το πεδίο {0} είναι υποχρεωτικό. + + + Απαιτούνται διαπιστευτήρια για αυτόν τον τύπο ενέργειας. + + + Αποστολή Email + + + Αποστολή SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Μεταφόρτωση FTP + + + Μεταφόρτωση SFTP + + + Μεταφόρτωση S3 + + + Μήνυμα Teams + + + Μήνυμα Slack + + + Μήνυμα Discord + + + Μεταφόρτωση Azure Blob + + + Μεταφόρτωση Box + + + Μεταφόρτωση Dropbox + + + Υπό Όρους + + + Ο τύπος συμβάντος ενεργοποίησης δεν μπορεί να αλλάξει μετά τη δημιουργία. + + + Προς + + + Διευθύνσεις email χωρισμένες με κόμμα. + + + Κοιν. + + + προαιρετικό + + + Θέμα + + + Προς (τηλέφωνο) + + + Μορφή E.164. Απαιτείται διαπιστευτήριο Twilio. + + + Διεύθυνση URL + + + Χρονικό Όριο (δευτερόλεπτα) + + + Απομακρυσμένη Διαδρομή + + + Όνομα Αρχείου + + + Αφήστε το κενό για αυτόματη δημιουργία ονόματος. + + + Κλειδί S3 (διαδρομή) + + + Πλήρες κλειδί / διαδρομή μέσα στο bucket. + + + Τίτλος Κάρτας + + + Χρώμα Θέματος + + + Δεκαεξαδική τιμή χωρίς # + + + Κανάλι + + + Αφήστε το κενό για χρήση του προεπιλεγμένου καναλιού του webhook. + + + Όνομα Χρήστη Bot + + + Emoji Εικονιδίου + + + Διεύθυνση URL Άβαταρ + + + Όνομα / Διαδρομή Blob + + + Αφήστε το κενό για αυτόματη δημιουργία διαδρομής. + + + Αναγνωριστικό Φακέλου + + + "0" = ριζικός φάκελος. + + + Διαδρομή Προορισμού + + + Λειτουργία Εγγραφής + + + Αντικατάσταση + + + Προσθήκη (μετονομασία σε περίπτωση διένεξης) + + + Ενημέρωση (αποτυχία αν δεν υπάρχει) + + + Διαπιστευτήριο + + + (κανένα) + + + Δεν βρέθηκαν αντίστοιχα διαπιστευτήρια για αυτόν τον τύπο ενέργειας. + + + Προσθέστε ένα τώρα → + + + Διαπιστευτήρια SMTP για την αποστολή email. + + + Διαπιστευτήρια Twilio για την αποστολή SMS. + + + Προαιρετικό διαπιστευτήριο ελέγχου ταυτότητας HTTP. + + + Διαπιστευτήρια διακομιστή FTP. + + + Διαπιστευτήρια διακομιστή SFTP. + + + Διαπιστευτήρια AWS S3 (κλειδί πρόσβασης + bucket). + + + Διαπιστευτήριο εισερχόμενου webhook Microsoft Teams. + + + Διαπιστευτήριο Slack (webhook ή διακριτικό bot). + + + Διαπιστευτήριο Discord (webhook ή διακριτικό bot). + + + Διαπιστευτήριο συμβολοσειράς σύνδεσης Azure Blob Storage. + + + Διαπιστευτήριο εφαρμογής Box (JWT). + + + Διαπιστευτήριο διακριτικού πρόσβασης Dropbox. + + + — επιλέξτε διαπιστευτήριο — + + + (κανένα / χωρίς έλεγχο ταυτότητας) + + + Έκφραση Συνθήκης + + + (προαιρετική) + + + Αφήστε το κενό για να εκτελείται πάντα αυτό το βήμα. Όταν οριστεί, το βήμα εκτελείται μόνο αν η συνθήκη αποτιμηθεί ως {0}. Χρησιμοποιεί τις ίδιες μεταβλητές με το πρότυπο εξόδου. + + + Οπτικός Σχεδιαστής + + + Ακατέργαστη Έκφραση + + + Προσθήκη Κανόνα + + + Οι πολλαπλοί κανόνες συνδέονται με AND — πρέπει να ισχύουν όλοι. + + + Δοκιμή Συνθήκης + + + περιέχει + + + δεν περιέχει + + + ισούται με + + + δεν ισούται με + + + ξεκινά με + + + τελειώνει με + + + είναι κενό + + + δεν είναι κενό + + + τιμή + + + Η τρέχουσα έκφραση είναι υπερβολικά σύνθετη για τον οπτικό σχεδιαστή. Επεξεργαστείτε τη στην καρτέλα «Ακατέργαστη Έκφραση». + + + Χωρίς συνθήκη — το βήμα θα εκτελείται πάντα. + + + Δοκιμή… + + + Αποτίμηση: "{0}" — το βήμα θα εκτελεστεί + + + Αποτίμηση: "{0}" — το βήμα θα παραλειφθεί + + + Έγκυρη σύνταξη (δεν υπάρχει δείγμα ωφέλιμου φορτίου για αποτίμηση) + + + Το αίτημα δοκιμής απέτυχε. + + + Χρησιμοποιήστε σύνταξη προτύπων Scriban. Κάντε κλικ στις μεταβλητές στον πίνακα στα δεξιά για να τις εισαγάγετε. + + + Κάντε κλικ σε μια μεταβλητή με την ένδειξη {0} για να την εισαγάγετε. + + + Κάντε κλικ σε μια μεταβλητή με την ένδειξη {0} για να εισαγάγετε ένα απόσπασμα βρόχου {1}. + + + βαθμωτή + + + πίνακας + + + Συλλογές Κλήσης (πίνακες) + + + Κάντε κλικ για να εισαγάγετε έναν βρόχο {0}. Διατίθεται για τα εναύσματα CallAdded, CallUpdated, CallClosed. + + + Πεδία: {0} + + + Μονάδα / Προσωπικό + + + Αποτυχία αποθήκευσης βήματος. + + + Η διαγραφή απέτυχε: {0} + + + π.χ. Νέα Κλήση: {0} + + + Ειδοποίηση Resgrid + + + Bot του Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx index 91122225f..a00034429 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.en.resx @@ -749,5 +749,326 @@ When a call closes, sends an MDM^T02 "Crisis Field Response" document to an interface engine over HTTPS: PID-3 is the EHR client id, MSH-10 the idempotency key, one OBX per released custom field. Succeeds only on an AA acknowledgement. + + Output Template + + + {0} is required. + + + A credential is required for this action type. + + + Send Email + + + Send SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Upload FTP + + + Upload SFTP + + + Upload S3 + + + Teams Message + + + Slack Message + + + Discord Message + + + Azure Blob + + + Box Upload + + + Dropbox Upload + + + Conditional + + + The trigger event type cannot be changed after creation. + + + To + + + Comma-separated email addresses. + + + Cc + + + optional + + + Subject + + + To (phone) + + + E.164 format. Requires a Twilio credential. + + + URL + + + Timeout (seconds) + + + Remote Path + + + Filename + + + Leave blank for auto-generated name. + + + S3 Key (path) + + + Full key / path within the bucket. + + + Card Title + + + Theme Color + + + Hex without # + + + Channel + + + Leave blank to use webhook default. + + + Bot Username + + + Icon Emoji + + + Avatar URL + + + Blob Name / Path + + + Leave blank for auto-generated path. + + + Folder ID + + + "0" = root folder. + + + Target Path + + + Write Mode + + + Overwrite + + + Add (rename on conflict) + + + Update (fail if not exists) + + + Credential + + + (none) + + + No matching credentials found for this action type. + + + Add one now → + + + SMTP credentials to send email from. + + + Twilio credentials for SMS delivery. + + + Optional HTTP auth credential. + + + FTP server credentials. + + + SFTP server credentials. + + + AWS S3 credentials (access key + bucket). + + + Microsoft Teams incoming webhook credential. + + + Slack webhook or bot token credential. + + + Discord webhook or bot token credential. + + + Azure Blob Storage connection string credential. + + + Box application credential (JWT). + + + Dropbox access token credential. + + + — select credential — + + + (none / unauthenticated) + + + Condition Expression + + + (optional) + + + Leave blank to always run this step. When set, the step only runs if the condition evaluates to {0}. Uses the same variables as the output template. + + + Visual Builder + + + Raw Expression + + + Add Rule + + + Multiple rules are joined with AND — all must be true. + + + Test Condition + + + contains + + + does not contain + + + equals + + + does not equal + + + starts with + + + ends with + + + is empty + + + is not empty + + + value + + + The current expression is too complex for the visual builder. Edit it in the Raw Expression tab. + + + No condition — step will always run. + + + Testing… + + + Evaluated: "{0}" — step will run + + + Evaluated: "{0}" — step will be skipped + + + Syntax valid (no sample payload to evaluate against) + + + Test request failed. + + + Use Scriban template syntax. Click variables in the panel on the right to insert them. + + + Click a {0} variable to insert it. + + + Click an {0} variable to insert a {1} loop snippet. + + + scalar + + + array + + + Call Collections (arrays) + + + Click to insert a {0} loop. Available for CallAdded, CallUpdated, CallClosed triggers. + + + Fields: {0} + + + Unit / Personnel + + + Failed to save step. + + + Delete failed: {0} + + + e.g. New Call: {0} + + + Resgrid Notification + + + Resgrid Bot + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx index c95dc71dc..b6dc03666 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.es.resx @@ -747,5 +747,326 @@ Al cerrarse una llamada, envía un documento MDM^T02 "Crisis Field Response" a un motor de interfaces por HTTPS: PID-3 es el id de cliente de la HCE, MSH-10 la clave de idempotencia y un OBX por cada campo personalizado autorizado. Solo tiene éxito con un acuse AA. + + Plantilla de salida + + + El campo {0} es obligatorio. + + + Se requiere una credencial para este tipo de acción. + + + Enviar correo electrónico + + + Enviar SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Subir por FTP + + + Subir por SFTP + + + Subir a S3 + + + Mensaje de Teams + + + Mensaje de Slack + + + Mensaje de Discord + + + Subir a Azure Blob + + + Subir a Box + + + Subir a Dropbox + + + Condicional + + + El tipo de evento desencadenador no se puede cambiar después de la creación. + + + Para + + + Direcciones de correo electrónico separadas por comas. + + + CC + + + opcional + + + Asunto + + + Para (teléfono) + + + Formato E.164. Requiere una credencial de Twilio. + + + URL + + + Tiempo de espera (segundos) + + + Ruta remota + + + Nombre de archivo + + + Deje en blanco para generar el nombre automáticamente. + + + Clave de S3 (ruta) + + + Clave / ruta completa dentro del bucket. + + + Título de la tarjeta + + + Color del tema + + + Hexadecimal sin # + + + Canal + + + Deje en blanco para usar el canal predeterminado del webhook. + + + Nombre de usuario del bot + + + Emoji de icono + + + URL del avatar + + + Nombre / ruta del blob + + + Deje en blanco para generar la ruta automáticamente. + + + ID de carpeta + + + "0" = carpeta raíz. + + + Ruta de destino + + + Modo de escritura + + + Sobrescribir + + + Agregar (renombrar si hay conflicto) + + + Actualizar (falla si no existe) + + + Credencial + + + (ninguna) + + + No se encontraron credenciales coincidentes para este tipo de acción. + + + Agregue una ahora → + + + Credenciales SMTP con las que enviar el correo electrónico. + + + Credenciales de Twilio para el envío de SMS. + + + Credencial de autenticación HTTP opcional. + + + Credenciales del servidor FTP. + + + Credenciales del servidor SFTP. + + + Credenciales de AWS S3 (clave de acceso + bucket). + + + Credencial de webhook entrante de Microsoft Teams. + + + Credencial de webhook o token de bot de Slack. + + + Credencial de webhook o token de bot de Discord. + + + Credencial de cadena de conexión de Azure Blob Storage. + + + Credencial de aplicación de Box (JWT). + + + Credencial de token de acceso de Dropbox. + + + — seleccione una credencial — + + + (ninguna / sin autenticación) + + + Expresión de condición + + + (opcional) + + + Deje en blanco para ejecutar siempre este paso. Si se define, el paso solo se ejecuta cuando la condición se evalúa como {0}. Usa las mismas variables que la plantilla de salida. + + + Editor visual + + + Expresión sin procesar + + + Agregar regla + + + Si hay varias reglas, se combinan con AND — todas deben cumplirse. + + + Probar condición + + + contiene + + + no contiene + + + es igual a + + + no es igual a + + + comienza con + + + termina con + + + está vacío + + + no está vacío + + + valor + + + La expresión actual es demasiado compleja para el editor visual. Edítela en la pestaña Expresión sin procesar. + + + Sin condición — el paso se ejecutará siempre. + + + Probando… + + + Evaluado: "{0}" — el paso se ejecutará + + + Evaluado: "{0}" — el paso se omitirá + + + Sintaxis válida (no hay datos de ejemplo con los que evaluarla) + + + La solicitud de prueba falló. + + + Use la sintaxis de plantillas de Scriban. Haga clic en las variables del panel de la derecha para insertarlas. + + + Haga clic en una variable {0} para insertarla. + + + Haga clic en una variable {0} para insertar un fragmento de bucle {1}. + + + escalar + + + matriz + + + Colecciones de la llamada (matrices) + + + Haga clic para insertar un bucle {0}. Disponible para los disparadores CallAdded, CallUpdated y CallClosed. + + + Campos: {0} + + + Unidad / Personal + + + No se pudo guardar el paso. + + + No se pudo eliminar: {0} + + + p. ej. Nueva llamada: {0} + + + Notificación de Resgrid + + + Bot de Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx index c196bfd10..99364cec0 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.fr.resx @@ -60,115 +60,115 @@ - Workflows + Flux de travail - Department Workflows + Flux de travail du département - New Workflow + Nouveau flux de travail - Credentials + Identifiants - Pending Runs + Exécutions en attente - No workflows have been created yet. + Aucun flux de travail n'a encore été créé. - Create your first workflow. + Créez votre premier flux de travail. Nom - Trigger Event + Événement déclencheur Statut - Created + Créé le Actions - Enabled + Activé - Disabled + Désactivé Modifier - Runs + Exécutions - Health + Santé Supprimer - Delete workflow {0}? + Supprimer le flux de travail {0} ? - New Workflow + Nouveau flux de travail - Create Workflow + Créer le flux de travail Description - Max Retry Count + Nombre maximal de nouvelles tentatives - Retry Backoff Base (seconds) + Délai de base entre les tentatives (secondes) - e.g. Notify Slack on New Call + p. ex. Notifier Slack lors d'un nouvel appel - Optional description + Description facultative Annuler - Edit Workflow + Modifier le flux de travail - Workflow Settings + Paramètres du flux de travail - Max Retries + Tentatives max. - Backoff (s) + Délai entre tentatives (s) - Save Changes + Enregistrer les modifications - Steps + Étapes - Add Step + Ajouter une étape - Action Type + Type d'action - Commande + Ordre - Template (first 120 chars) + Modèle (120 premiers caractères) Oui @@ -177,67 +177,67 @@ Non - Are you sure you want to delete this step? This action cannot be undone. + Voulez-vous vraiment supprimer cette étape ? Cette action est irréversible. - Delete Step + Supprimer l'étape - Template Variables + Variables du modèle - Common variables (always available): + Variables communes (toujours disponibles) : - Call event variables: + Variables des événements d'appel : - Workflow Credentials + Identifiants des flux de travail - Stored Credentials + Identifiants enregistrés - Add Credential + Ajouter des identifiants - No credentials configured. Add credentials to use with workflow action steps. + Aucun identifiant configuré. Ajoutez des identifiants à utiliser dans les étapes d'action des flux de travail. Type - Secret Data + Données secrètes - Delete credential {0}? + Supprimer les identifiants {0} ? - Please correct the following errors before saving. + Veuillez corriger les erreurs suivantes avant d'enregistrer. - Add Credential + Ajouter des identifiants - Edit Credential + Modifier les identifiants - Credential Details + Détails des identifiants Type - Save Credential + Enregistrer les identifiants - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + Les secrets existants sont stockés chiffrés et ne peuvent pas être affichés. Remplissez les champs ci-dessous pour les remplacer, ou laissez tous les champs vides pour conserver les secrets actuels. - SMTP Settings + Paramètres SMTP - Host + Hôte Port @@ -249,34 +249,34 @@ Mot de passe - Use SSL/TLS + Utiliser SSL/TLS - From Address + Adresse de l'expéditeur - The email address that will appear in the "From" field of sent messages. + L'adresse e-mail qui apparaîtra dans le champ « De » des messages envoyés. - Twilio Settings + Paramètres Twilio - Account SID + SID du compte - Auth Token + Jeton d'authentification - From Number + Numéro de l'expéditeur - E.164 format, e.g. +15551234567 + Format E.164, p. ex. +15551234567 - FTP Settings + Paramètres FTP - Host + Hôte Port @@ -288,13 +288,13 @@ Mot de passe - Use Passive Mode + Utiliser le mode passif - SFTP Settings + Paramètres SFTP - Host + Hôte Port @@ -306,37 +306,37 @@ Mot de passe - Private Key (PEM) + Clé privée (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Fournissez un mot de passe ou une clé privée (ou les deux si la clé est protégée par une phrase secrète). - AWS S3 Settings + Paramètres AWS S3 - Access Key ID + ID de clé d'accès - Secret Access Key + Clé d'accès secrète - Region + Région - Bucket Name + Nom du compartiment - HTTP Bearer Token + Jeton Bearer HTTP - Bearer Token + Jeton Bearer - Sent as Authorization: Bearer <token> + Envoyé sous la forme Authorization: Bearer <token> - HTTP Basic Auth + Authentification HTTP Basic Nom d'utilisateur @@ -345,148 +345,148 @@ Mot de passe - HTTP API Key + Clé API HTTP - Header Name + Nom de l'en-tête - The HTTP header name that carries the API key. + Le nom de l'en-tête HTTP qui transporte la clé API. - API Key + Clé API - Microsoft Teams Webhook + Webhook Microsoft Teams - Incoming Webhook URL + URL du webhook entrant - Create an Incoming Webhook connector inside the desired Teams channel. + Créez un connecteur Webhook entrant dans le canal Teams souhaité. Slack - Incoming Webhook URL + URL du webhook entrant - Bot Token + Jeton de bot - Provide a webhook URL, a bot token, or both. + Fournissez une URL de webhook, un jeton de bot, ou les deux. Discord - Webhook URL + URL du webhook - Bot Token + Jeton de bot - Provide a webhook URL, a bot token, or both. + Fournissez une URL de webhook, un jeton de bot, ou les deux. Azure Blob Storage - Connection String + Chaîne de connexion - Container Name + Nom du conteneur Box - Client ID + ID client - Client Secret + Secret client - Enterprise ID + ID d'entreprise - Public Key ID + ID de clé publique - Private Key (PEM) + Clé privée (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + La clé privée RSA issue de la configuration de votre application JWT Box (format PEM). - Private Key Passphrase + Phrase secrète de la clé privée - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Phrase secrète facultative servant à déchiffrer la clé privée, si celle-ci est protégée par une phrase secrète. Dropbox - Refresh Token + Jeton d'actualisation - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Jeton d'actualisation OAuth2 de longue durée obtenu via le flux d'autorisation OAuth2 de Dropbox. - App Key + Clé d'application - App Secret + Secret d'application - Pending / Running Workflows + Flux de travail en attente / en cours - Active Runs + Exécutions actives - Clear All Pending + Effacer toutes les exécutions en attente - Cancel ALL pending runs for this department? + Annuler TOUTES les exécutions en attente de ce département ? - No pending or running workflow runs. + Aucune exécution de flux de travail en attente ou en cours. - Run ID + ID d'exécution - Workflow + Flux de travail - Queued + Mise en file d'attente - Attempt + Tentative Annuler - Cancel this run? + Annuler cette exécution ? - Workflow Runs + Exécutions du flux de travail - Run History + Historique des exécutions - No runs found for this workflow. + Aucune exécution trouvée pour ce flux de travail. - Started + Début - Completed + Fin Erreur @@ -498,46 +498,46 @@ Suivant - Workflow Health + Santé du flux de travail - No health data available for this workflow yet. + Aucune donnée de santé n'est encore disponible pour ce flux de travail. - Last 24 Hours + Dernières 24 heures - Last 7 Days + 7 derniers jours - Last 30 Days + 30 derniers jours - Successful + Réussies - Failed + Échouées Total - Success rate + Taux de réussite - Performance + Performances - Avg Duration + Durée moyenne - Last Run + Dernière exécution - Last Error + Dernière erreur - View Runs + Voir les exécutions Jamais @@ -726,4 +726,325 @@ À la clôture d'un appel, envoie un document MDM^T02 « Crisis Field Response » à un moteur d'interfaces via HTTPS : PID-3 est l'identifiant client du DPI, MSH-10 la clé d'idempotence, un OBX par champ personnalisé autorisé. Réussit uniquement sur un accusé AA. + + Modèle de sortie + + + Le champ {0} est obligatoire. + + + Des identifiants sont requis pour ce type d'action. + + + Envoyer un e-mail + + + Envoyer un SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Téléversement FTP + + + Téléversement SFTP + + + Téléversement S3 + + + Message Teams + + + Message Slack + + + Message Discord + + + Téléversement Azure Blob + + + Téléversement Box + + + Téléversement Dropbox + + + Conditionnelle + + + Le type d'événement déclencheur ne peut pas être modifié après la création. + + + À + + + Adresses e-mail séparées par des virgules. + + + Cc + + + facultatif + + + Objet + + + À (téléphone) + + + Format E.164. Nécessite des identifiants Twilio. + + + URL + + + Délai d'expiration (secondes) + + + Chemin distant + + + Nom de fichier + + + Laissez vide pour générer un nom automatiquement. + + + Clé S3 (chemin) + + + Clé / chemin complet dans le compartiment. + + + Titre de la carte + + + Couleur du thème + + + Hexadécimal sans # + + + Canal + + + Laissez vide pour utiliser le canal par défaut du webhook. + + + Nom d'utilisateur du bot + + + Emoji d'icône + + + URL de l'avatar + + + Nom / chemin du blob + + + Laissez vide pour générer un chemin automatiquement. + + + ID du dossier + + + "0" = dossier racine. + + + Chemin cible + + + Mode d'écriture + + + Écraser + + + Ajouter (renommer en cas de conflit) + + + Mettre à jour (échec si inexistant) + + + Identifiants + + + (aucun) + + + Aucun identifiant correspondant trouvé pour ce type d'action. + + + En ajouter maintenant → + + + Identifiants SMTP utilisés pour l'envoi des e-mails. + + + Identifiants Twilio pour l'envoi des SMS. + + + Identifiants d'authentification HTTP facultatifs. + + + Identifiants du serveur FTP. + + + Identifiants du serveur SFTP. + + + Identifiants AWS S3 (clé d'accès + compartiment). + + + Identifiants du webhook entrant Microsoft Teams. + + + Identifiants Slack de type webhook ou jeton de bot. + + + Identifiants Discord de type webhook ou jeton de bot. + + + Identifiants de chaîne de connexion Azure Blob Storage. + + + Identifiants d'application Box (JWT). + + + Identifiants de jeton d'accès Dropbox. + + + — sélectionner des identifiants — + + + (aucun / non authentifié) + + + Expression de condition + + + (facultative) + + + Laissez vide pour toujours exécuter cette étape. Si elle est renseignée, l'étape ne s'exécute que si la condition est évaluée à {0}. Utilise les mêmes variables que le modèle de sortie. + + + Éditeur visuel + + + Expression brute + + + Ajouter une règle + + + Plusieurs règles sont combinées avec AND — toutes doivent être vraies. + + + Tester la condition + + + contient + + + ne contient pas + + + est égal à + + + n'est pas égal à + + + commence par + + + se termine par + + + est vide + + + n'est pas vide + + + valeur + + + L'expression actuelle est trop complexe pour l'éditeur visuel. Modifiez-la dans l'onglet « Expression brute ». + + + Aucune condition — l'étape s'exécutera toujours. + + + Test en cours… + + + Évaluation : "{0}" — l'étape sera exécutée + + + Évaluation : "{0}" — l'étape sera ignorée + + + Syntaxe valide (aucune charge utile d'exemple pour l'évaluer) + + + La requête de test a échoué. + + + Utilisez la syntaxe de modèle Scriban. Cliquez sur les variables du panneau de droite pour les insérer. + + + Cliquez sur une variable {0} pour l'insérer. + + + Cliquez sur une variable {0} pour insérer une boucle {1}. + + + scalaire + + + tableau + + + Collections de l'appel (tableaux) + + + Cliquez pour insérer une boucle {0}. Disponible pour les déclencheurs CallAdded, CallUpdated, CallClosed. + + + Champs : {0} + + + Unité / Personnel + + + Échec de l'enregistrement de l'étape. + + + Échec de la suppression : {0} + + + p. ex. Nouvel appel : {0} + + + Notification Resgrid + + + Bot Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx index ab280b445..adab3596b 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.it.resx @@ -60,115 +60,115 @@ - Workflows + Flussi di lavoro - Department Workflows + Flussi di lavoro del dipartimento - New Workflow + Nuovo flusso di lavoro - Credentials + Credenziali - Pending Runs + Esecuzioni in sospeso - No workflows have been created yet. + Non è stato ancora creato alcun flusso di lavoro. - Create your first workflow. + Crea il tuo primo flusso di lavoro. Nome - Trigger Event + Evento di attivazione Stato - Created + Data di creazione Azioni - Enabled + Attivato - Disabled + Disattivato Modifica - Runs + Esecuzioni - Health + Integrità Elimina - Delete workflow {0}? + Eliminare il flusso di lavoro {0}? - New Workflow + Nuovo flusso di lavoro - Create Workflow + Crea flusso di lavoro Descrizione - Max Retry Count + Numero massimo di tentativi - Retry Backoff Base (seconds) + Intervallo base tra i tentativi (secondi) - e.g. Notify Slack on New Call + es. Notifica Slack per nuova chiamata - Optional description + Descrizione facoltativa Annulla - Edit Workflow + Modifica flusso di lavoro - Workflow Settings + Impostazioni del flusso di lavoro - Max Retries + Tentativi max - Backoff (s) + Intervallo tentativi (s) - Save Changes + Salva modifiche - Steps + Passaggi - Add Step + Aggiungi passaggio - Action Type + Tipo di azione Ordine - Template (first 120 chars) + Modello (primi 120 caratteri) Sì @@ -177,70 +177,70 @@ No - Are you sure you want to delete this step? This action cannot be undone. + Vuoi davvero eliminare questo passaggio? Questa azione non può essere annullata. - Delete Step + Elimina passaggio - Template Variables + Variabili del modello - Common variables (always available): + Variabili comuni (sempre disponibili): - Call event variables: + Variabili degli eventi di chiamata: - Workflow Credentials + Credenziali dei flussi di lavoro - Stored Credentials + Credenziali memorizzate - Add Credential + Aggiungi credenziali - No credentials configured. Add credentials to use with workflow action steps. + Nessuna credenziale configurata. Aggiungi le credenziali da usare nei passaggi di azione dei flussi di lavoro. Tipo - Secret Data + Dati segreti - Delete credential {0}? + Eliminare le credenziali {0}? - Please correct the following errors before saving. + Correggi i seguenti errori prima di salvare. - Add Credential + Aggiungi credenziali - Edit Credential + Modifica credenziali - Credential Details + Dettagli delle credenziali Tipo - Save Credential + Salva credenziali - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + I segreti esistenti sono memorizzati in forma cifrata e non possono essere visualizzati. Compila i campi qui sotto per sostituirli oppure lascia vuoti tutti i campi per mantenere i segreti attuali. - SMTP Settings + Impostazioni SMTP Host - Port + Porta Nome utente @@ -249,37 +249,37 @@ Password - Use SSL/TLS + Usa SSL/TLS - From Address + Indirizzo mittente - The email address that will appear in the "From" field of sent messages. + L'indirizzo e-mail che comparirà nel campo "Da" dei messaggi inviati. - Twilio Settings + Impostazioni Twilio - Account SID + SID account - Auth Token + Token di autenticazione - From Number + Numero mittente - E.164 format, e.g. +15551234567 + Formato E.164, ad es. +15551234567 - FTP Settings + Impostazioni FTP Host - Port + Porta Nome utente @@ -288,16 +288,16 @@ Password - Use Passive Mode + Usa la modalità passiva - SFTP Settings + Impostazioni SFTP Host - Port + Porta Nome utente @@ -306,37 +306,37 @@ Password - Private Key (PEM) + Chiave privata (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Fornisci una password o una chiave privata (o entrambe se la chiave è protetta da passphrase). - AWS S3 Settings + Impostazioni AWS S3 - Access Key ID + ID chiave di accesso - Secret Access Key + Chiave di accesso segreta - Region + Regione - Bucket Name + Nome del bucket - HTTP Bearer Token + Token Bearer HTTP - Bearer Token + Token Bearer - Sent as Authorization: Bearer <token> + Inviato come Authorization: Bearer <token> - HTTP Basic Auth + Autenticazione HTTP Basic Nome utente @@ -345,148 +345,148 @@ Password - HTTP API Key + Chiave API HTTP - Header Name + Nome dell'intestazione - The HTTP header name that carries the API key. + Il nome dell'intestazione HTTP che contiene la chiave API. - API Key + Chiave API - Microsoft Teams Webhook + Webhook di Microsoft Teams - Incoming Webhook URL + URL del webhook in ingresso - Create an Incoming Webhook connector inside the desired Teams channel. + Crea un connettore Webhook in ingresso nel canale Teams desiderato. Slack - Incoming Webhook URL + URL del webhook in ingresso - Bot Token + Token del bot - Provide a webhook URL, a bot token, or both. + Fornisci un URL del webhook, un token del bot o entrambi. Discord - Webhook URL + URL del webhook - Bot Token + Token del bot - Provide a webhook URL, a bot token, or both. + Fornisci un URL del webhook, un token del bot o entrambi. Azure Blob Storage - Connection String + Stringa di connessione - Container Name + Nome del contenitore Box - Client ID + ID client - Client Secret + Segreto client - Enterprise ID + ID enterprise - Public Key ID + ID chiave pubblica - Private Key (PEM) + Chiave privata (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + La chiave privata RSA della configurazione della tua applicazione JWT di Box (formato PEM). - Private Key Passphrase + Passphrase della chiave privata - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Passphrase facoltativa usata per decifrare la chiave privata, se è protetta da passphrase. Dropbox - Refresh Token + Token di aggiornamento - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Token di aggiornamento OAuth2 a lunga durata ottenuto dal flusso di autorizzazione OAuth2 di Dropbox. - App Key + Chiave app - App Secret + Segreto app - Pending / Running Workflows + Flussi di lavoro in sospeso / in esecuzione - Active Runs + Esecuzioni attive - Clear All Pending + Annulla tutte le esecuzioni in sospeso - Cancel ALL pending runs for this department? + Annullare TUTTE le esecuzioni in sospeso per questo dipartimento? - No pending or running workflow runs. + Nessuna esecuzione di flusso di lavoro in sospeso o in corso. - Run ID + ID esecuzione - Workflow + Flusso di lavoro - Queued + In coda - Attempt + Tentativo Annulla - Cancel this run? + Annullare questa esecuzione? - Workflow Runs + Esecuzioni del flusso di lavoro - Run History + Cronologia delle esecuzioni - No runs found for this workflow. + Nessuna esecuzione trovata per questo flusso di lavoro. - Started + Avvio - Completed + Completamento Errore @@ -498,46 +498,46 @@ Avanti - Workflow Health + Integrità del flusso di lavoro - No health data available for this workflow yet. + Non sono ancora disponibili dati sull'integrità di questo flusso di lavoro. - Last 24 Hours + Ultime 24 ore - Last 7 Days + Ultimi 7 giorni - Last 30 Days + Ultimi 30 giorni - Successful + Riuscite - Failed + Non riuscite - Total + Totale - Success rate + Percentuale di successo - Performance + Prestazioni - Avg Duration + Durata media - Last Run + Ultima esecuzione - Last Error + Ultimo errore - View Runs + Visualizza esecuzioni Mai @@ -589,7 +589,7 @@ Dettaglio - «Registro aggiunto» è un trigger di compatibilità. Con i Rapporti attivi scatta alla finalizzazione di un rapporto di intervento, formazione, lavoro, riunione, medico legale o richiamo, con i vecchi campi del registro inclusa la narrativa, e viene mantenuto per due versioni minori o dodici mesi dopo l'attivazione. Usate i trigger Rapporto per i nuovi flussi. + «Log aggiunto» è un trigger di compatibilità. Con i Registri attivi scatta alla finalizzazione di un registro di intervento, formazione, lavoro, riunione, medico legale o richiamo, con i vecchi campi del log inclusa la narrativa, e viene mantenuto per due versioni minori o dodici mesi dopo l'attivazione. Usa i trigger Registro per i nuovi flussi. ID evento @@ -726,4 +726,325 @@ Alla chiusura di una chiamata, invia un documento MDM^T02 "Crisis Field Response" a un motore di integrazione via HTTPS: PID-3 è l'id cliente della cartella clinica, MSH-10 la chiave di idempotenza, un OBX per ogni campo personalizzato autorizzato. Riesce solo con una conferma AA. + + Modello di output + + + Il campo {0} è obbligatorio. + + + Per questo tipo di azione sono necessarie le credenziali. + + + Invia e-mail + + + Invia SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Caricamento FTP + + + Caricamento SFTP + + + Caricamento S3 + + + Messaggio Teams + + + Messaggio Slack + + + Messaggio Discord + + + Caricamento Azure Blob + + + Caricamento Box + + + Caricamento Dropbox + + + Condizionale + + + Il tipo di evento di attivazione non può essere modificato dopo la creazione. + + + A + + + Indirizzi e-mail separati da virgole. + + + Cc + + + facoltativo + + + Oggetto + + + A (telefono) + + + Formato E.164. Richiede credenziali Twilio. + + + URL + + + Timeout (secondi) + + + Percorso remoto + + + Nome file + + + Lascia vuoto per generare il nome automaticamente. + + + Chiave S3 (percorso) + + + Chiave / percorso completo all'interno del bucket. + + + Titolo della scheda + + + Colore del tema + + + Esadecimale senza # + + + Canale + + + Lascia vuoto per usare il canale predefinito del webhook. + + + Nome utente del bot + + + Emoji dell'icona + + + URL dell'avatar + + + Nome / percorso del blob + + + Lascia vuoto per generare il percorso automaticamente. + + + ID cartella + + + "0" = cartella principale. + + + Percorso di destinazione + + + Modalità di scrittura + + + Sovrascrivi + + + Aggiungi (rinomina in caso di conflitto) + + + Aggiorna (errore se non esiste) + + + Credenziali + + + (nessuna) + + + Nessuna credenziale corrispondente trovata per questo tipo di azione. + + + Aggiungine una ora → + + + Credenziali SMTP da usare per l'invio delle e-mail. + + + Credenziali Twilio per l'invio degli SMS. + + + Credenziali di autenticazione HTTP facoltative. + + + Credenziali del server FTP. + + + Credenziali del server SFTP. + + + Credenziali AWS S3 (chiave di accesso + bucket). + + + Credenziali del webhook in ingresso di Microsoft Teams. + + + Credenziali Slack con webhook o token del bot. + + + Credenziali Discord con webhook o token del bot. + + + Credenziali con stringa di connessione di Azure Blob Storage. + + + Credenziali dell'applicazione Box (JWT). + + + Credenziali con token di accesso Dropbox. + + + — seleziona le credenziali — + + + (nessuna / senza autenticazione) + + + Espressione della condizione + + + (facoltativa) + + + Lascia vuoto per eseguire sempre questo passaggio. Se impostata, il passaggio viene eseguito solo se la condizione restituisce {0}. Usa le stesse variabili del modello di output. + + + Editor visivo + + + Espressione testuale + + + Aggiungi regola + + + Più regole vengono unite con AND — devono essere tutte vere. + + + Verifica condizione + + + contiene + + + non contiene + + + è uguale a + + + è diverso da + + + inizia con + + + termina con + + + è vuoto + + + non è vuoto + + + valore + + + L'espressione attuale è troppo complessa per l'editor visivo. Modificala nella scheda Espressione testuale. + + + Nessuna condizione — il passaggio verrà sempre eseguito. + + + Verifica in corso… + + + Valutazione: "{0}" — il passaggio verrà eseguito + + + Valutazione: "{0}" — il passaggio verrà saltato + + + Sintassi valida (nessun payload di esempio su cui eseguire la valutazione) + + + La richiesta di verifica non è riuscita. + + + Usa la sintassi dei modelli Scriban. Fai clic sulle variabili nel pannello a destra per inserirle. + + + Fai clic su una variabile {0} per inserirla. + + + Fai clic su una variabile {0} per inserire un frammento di ciclo {1}. + + + scalare + + + array + + + Raccolte della chiamata (array) + + + Fai clic per inserire un ciclo {0}. Disponibile per gli eventi di attivazione CallAdded, CallUpdated, CallClosed. + + + Campi: {0} + + + Unità / Personale + + + Impossibile salvare il passaggio. + + + Eliminazione non riuscita: {0} + + + es. Nuova chiamata: {0} + + + Notifica Resgrid + + + Bot Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx index 52b2a05f5..ae2f46aba 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.pl.resx @@ -60,115 +60,115 @@ - Workflows + Przepływy pracy - Department Workflows + Przepływy pracy działu - New Workflow + Nowy przepływ pracy - Credentials + Poświadczenia - Pending Runs + Oczekujące uruchomienia - No workflows have been created yet. + Nie utworzono jeszcze żadnych przepływów pracy. - Create your first workflow. + Utwórz swój pierwszy przepływ pracy. Nazwa - Trigger Event + Zdarzenie wyzwalające Status - Created + Utworzono Akcje - Enabled + Włączony - Disabled + Wyłączony Edytuj - Runs + Uruchomienia - Health + Kondycja Usuń - Delete workflow {0}? + Usunąć przepływ pracy {0}? - New Workflow + Nowy przepływ pracy - Create Workflow + Utwórz przepływ pracy Opis - Max Retry Count + Maksymalna liczba ponowień - Retry Backoff Base (seconds) + Bazowe opóźnienie ponowień (sekundy) - e.g. Notify Slack on New Call + np. Powiadom Slack o nowym zgłoszeniu - Optional description + Opcjonalny opis Anuluj - Edit Workflow + Edytuj przepływ pracy - Workflow Settings + Ustawienia przepływu pracy - Max Retries + Maks. ponowień - Backoff (s) + Opóźnienie (s) - Save Changes + Zapisz zmiany - Steps + Kroki - Add Step + Dodaj krok - Action Type + Typ akcji - Zamówienie + Kolejność - Template (first 120 chars) + Szablon (pierwsze 120 znaków) Tak @@ -177,64 +177,64 @@ Nie - Are you sure you want to delete this step? This action cannot be undone. + Czy na pewno chcesz usunąć ten krok? Tej operacji nie można cofnąć. - Delete Step + Usuń krok - Template Variables + Zmienne szablonu - Common variables (always available): + Zmienne wspólne (zawsze dostępne): - Call event variables: + Zmienne zdarzenia zgłoszenia: - Workflow Credentials + Poświadczenia przepływów pracy - Stored Credentials + Zapisane poświadczenia - Add Credential + Dodaj poświadczenie - No credentials configured. Add credentials to use with workflow action steps. + Nie skonfigurowano żadnych poświadczeń. Dodaj poświadczenia, aby używać ich w krokach akcji przepływów pracy. Typ - Secret Data + Dane poufne - Delete credential {0}? + Usunąć poświadczenie {0}? - Please correct the following errors before saving. + Przed zapisaniem popraw następujące błędy. - Add Credential + Dodaj poświadczenie - Edit Credential + Edytuj poświadczenie - Credential Details + Szczegóły poświadczenia Typ - Save Credential + Zapisz poświadczenie - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + Istniejące dane poufne są przechowywane w postaci zaszyfrowanej i nie można ich wyświetlić. Wypełnij poniższe pola, aby je zastąpić, lub pozostaw wszystkie pola puste, aby zachować bieżące dane poufne. - SMTP Settings + Ustawienia SMTP Host @@ -249,31 +249,31 @@ Hasło - Use SSL/TLS + Użyj SSL/TLS - From Address + Adres nadawcy - The email address that will appear in the "From" field of sent messages. + Adres e-mail, który pojawi się w polu „Od” wysyłanych wiadomości. - Twilio Settings + Ustawienia Twilio - Account SID + SID konta - Auth Token + Token uwierzytelniania - From Number + Numer nadawcy - E.164 format, e.g. +15551234567 + Format E.164, np. +15551234567 - FTP Settings + Ustawienia FTP Host @@ -288,10 +288,10 @@ Hasło - Use Passive Mode + Użyj trybu pasywnego - SFTP Settings + Ustawienia SFTP Host @@ -306,37 +306,37 @@ Hasło - Private Key (PEM) + Klucz prywatny (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Podaj hasło lub klucz prywatny (albo oba, jeśli klucz jest chroniony hasłem). - AWS S3 Settings + Ustawienia AWS S3 - Access Key ID + Identyfikator klucza dostępu - Secret Access Key + Tajny klucz dostępu Region - Bucket Name + Nazwa zasobnika - HTTP Bearer Token + Token HTTP Bearer - Bearer Token + Token Bearer - Sent as Authorization: Bearer <token> + Wysyłany jako nagłówek Authorization: Bearer <token> - HTTP Basic Auth + Uwierzytelnianie HTTP Basic Nazwa użytkownika @@ -345,148 +345,148 @@ Hasło - HTTP API Key + Klucz API HTTP - Header Name + Nazwa nagłówka - The HTTP header name that carries the API key. + Nazwa nagłówka HTTP, w którym przesyłany jest klucz API. - API Key + Klucz API - Microsoft Teams Webhook + Webhook Microsoft Teams - Incoming Webhook URL + Adres URL przychodzącego webhooka - Create an Incoming Webhook connector inside the desired Teams channel. + Utwórz łącznik przychodzącego webhooka (Incoming Webhook) w wybranym kanale Teams. Slack - Incoming Webhook URL + Adres URL przychodzącego webhooka - Bot Token + Token bota - Provide a webhook URL, a bot token, or both. + Podaj adres URL webhooka, token bota lub oba. Discord - Webhook URL + Adres URL webhooka - Bot Token + Token bota - Provide a webhook URL, a bot token, or both. + Podaj adres URL webhooka, token bota lub oba. Azure Blob Storage - Connection String + Parametry połączenia - Container Name + Nazwa kontenera Box - Client ID + Identyfikator klienta - Client Secret + Klucz tajny klienta - Enterprise ID + Identyfikator przedsiębiorstwa - Public Key ID + Identyfikator klucza publicznego - Private Key (PEM) + Klucz prywatny (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + Klucz prywatny RSA z konfiguracji Twojej aplikacji Box JWT (format PEM). - Private Key Passphrase + Hasło klucza prywatnego - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Opcjonalne hasło służące do odszyfrowania klucza prywatnego, jeśli jest on chroniony hasłem. Dropbox - Refresh Token + Token odświeżania - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Długotrwały token odświeżania OAuth2 uzyskany w procesie autoryzacji OAuth2 Dropbox. - App Key + Klucz aplikacji - App Secret + Klucz tajny aplikacji - Pending / Running Workflows + Oczekujące / wykonywane przepływy pracy - Active Runs + Aktywne uruchomienia - Clear All Pending + Wyczyść wszystkie oczekujące - Cancel ALL pending runs for this department? + Anulować WSZYSTKIE oczekujące uruchomienia dla tego działu? - No pending or running workflow runs. + Brak oczekujących lub wykonywanych uruchomień przepływów pracy. - Run ID + ID uruchomienia - Workflow + Przepływ pracy - Queued + Dodano do kolejki - Attempt + Próba Anuluj - Cancel this run? + Anulować to uruchomienie? - Workflow Runs + Uruchomienia przepływu pracy - Run History + Historia uruchomień - No runs found for this workflow. + Nie znaleziono uruchomień dla tego przepływu pracy. - Started + Rozpoczęto - Completed + Zakończono Błąd @@ -498,46 +498,46 @@ Następny - Workflow Health + Kondycja przepływu pracy - No health data available for this workflow yet. + Brak jeszcze danych o kondycji dla tego przepływu pracy. - Last 24 Hours + Ostatnie 24 godziny - Last 7 Days + Ostatnie 7 dni - Last 30 Days + Ostatnie 30 dni - Successful + Udane - Failed + Nieudane - Total + Łącznie - Success rate + Wskaźnik powodzenia - Performance + Wydajność - Avg Duration + Śr. czas trwania - Last Run + Ostatnie uruchomienie - Last Error + Ostatni błąd - View Runs + Wyświetl uruchomienia Nigdy @@ -726,4 +726,325 @@ Po zamknięciu zgłoszenia wysyła dokument MDM^T02 „Crisis Field Response” do silnika integracyjnego przez HTTPS: PID-3 to identyfikator klienta w dokumentacji medycznej, MSH-10 to klucz idempotencji, jeden OBX na każde udostępnione pole niestandardowe. Kończy się sukcesem tylko przy potwierdzeniu AA. + + Szablon wyjściowy + + + Pole {0} jest wymagane. + + + Ten typ akcji wymaga poświadczeń. + + + Wyślij e-mail + + + Wyślij SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Przesyłanie FTP + + + Przesyłanie SFTP + + + Przesyłanie do S3 + + + Wiadomość Teams + + + Wiadomość Slack + + + Wiadomość Discord + + + Przesyłanie do Azure Blob + + + Przesyłanie do Box + + + Przesyłanie do Dropbox + + + Warunkowy + + + Typu zdarzenia wyzwalającego nie można zmienić po utworzeniu. + + + Do + + + Adresy e-mail rozdzielone przecinkami. + + + DW + + + opcjonalnie + + + Temat + + + Do (telefon) + + + Format E.164. Wymaga poświadczenia Twilio. + + + Adres URL + + + Limit czasu (sekundy) + + + Ścieżka zdalna + + + Nazwa pliku + + + Pozostaw puste, aby nazwa została wygenerowana automatycznie. + + + Klucz S3 (ścieżka) + + + Pełny klucz / ścieżka w zasobniku. + + + Tytuł karty + + + Kolor motywu + + + Kod szesnastkowy bez # + + + Kanał + + + Pozostaw puste, aby użyć domyślnego kanału webhooka. + + + Nazwa użytkownika bota + + + Emoji ikony + + + Adres URL awatara + + + Nazwa / ścieżka obiektu blob + + + Pozostaw puste, aby ścieżka została wygenerowana automatycznie. + + + ID folderu + + + "0" = folder główny. + + + Ścieżka docelowa + + + Tryb zapisu + + + Nadpisz + + + Dodaj (zmień nazwę w razie konfliktu) + + + Aktualizuj (błąd, jeśli plik nie istnieje) + + + Poświadczenie + + + (brak) + + + Nie znaleziono pasujących poświadczeń dla tego typu akcji. + + + Dodaj teraz → + + + Poświadczenia SMTP używane do wysyłania e-maili. + + + Poświadczenia Twilio do wysyłania SMS-ów. + + + Opcjonalne poświadczenie uwierzytelniania HTTP. + + + Poświadczenia serwera FTP. + + + Poświadczenia serwera SFTP. + + + Poświadczenia AWS S3 (klucz dostępu + zasobnik). + + + Poświadczenie przychodzącego webhooka Microsoft Teams. + + + Poświadczenie webhooka lub tokenu bota Slack. + + + Poświadczenie webhooka lub tokenu bota Discord. + + + Poświadczenie z parametrami połączenia Azure Blob Storage. + + + Poświadczenie aplikacji Box (JWT). + + + Poświadczenie z tokenem dostępu Dropbox. + + + — wybierz poświadczenie — + + + (brak / bez uwierzytelniania) + + + Wyrażenie warunku + + + (opcjonalnie) + + + Pozostaw puste, aby ten krok był zawsze uruchamiany. Jeśli wyrażenie jest ustawione, krok zostanie uruchomiony tylko wtedy, gdy warunek zwróci wartość {0}. Wyrażenie korzysta z tych samych zmiennych co szablon wyjściowy. + + + Kreator wizualny + + + Surowe wyrażenie + + + Dodaj regułę + + + Wiele reguł łączy się operatorem AND — wszystkie muszą być spełnione. + + + Testuj warunek + + + zawiera + + + nie zawiera + + + równa się + + + nie równa się + + + zaczyna się od + + + kończy się na + + + jest puste + + + nie jest puste + + + wartość + + + Bieżące wyrażenie jest zbyt złożone dla kreatora wizualnego. Edytuj je na karcie Surowe wyrażenie. + + + Brak warunku — krok będzie zawsze uruchamiany. + + + Testowanie… + + + Wynik: "{0}" — krok zostanie uruchomiony + + + Wynik: "{0}" — krok zostanie pominięty + + + Składnia poprawna (brak przykładowych danych, na których można obliczyć wynik) + + + Żądanie testowe nie powiodło się. + + + Użyj składni szablonów Scriban. Kliknij zmienne w panelu po prawej, aby je wstawić. + + + Kliknij zmienną typu {0}, aby ją wstawić. + + + Kliknij zmienną typu {0}, aby wstawić fragment pętli {1}. + + + skalar + + + tablica + + + Kolekcje zgłoszenia (tablice) + + + Kliknij, aby wstawić pętlę {0}. Dostępne dla wyzwalaczy CallAdded, CallUpdated, CallClosed. + + + Pola: {0} + + + Jednostka / personel + + + Nie udało się zapisać kroku. + + + Usuwanie nie powiodło się: {0} + + + np. Nowe zgłoszenie: {0} + + + Powiadomienie Resgrid + + + Bot Resgrid + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx index 980365433..b02d2f0e6 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.resx @@ -746,5 +746,326 @@ When a call closes, sends an MDM^T02 "Crisis Field Response" document to an interface engine over HTTPS: PID-3 is the EHR client id, MSH-10 the idempotency key, one OBX per released custom field. Succeeds only on an AA acknowledgement. + + Output Template + + + {0} is required. + + + A credential is required for this action type. + + + Send Email + + + Send SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Upload FTP + + + Upload SFTP + + + Upload S3 + + + Teams Message + + + Slack Message + + + Discord Message + + + Azure Blob + + + Box Upload + + + Dropbox Upload + + + Conditional + + + The trigger event type cannot be changed after creation. + + + To + + + Comma-separated email addresses. + + + Cc + + + optional + + + Subject + + + To (phone) + + + E.164 format. Requires a Twilio credential. + + + URL + + + Timeout (seconds) + + + Remote Path + + + Filename + + + Leave blank for auto-generated name. + + + S3 Key (path) + + + Full key / path within the bucket. + + + Card Title + + + Theme Color + + + Hex without # + + + Channel + + + Leave blank to use webhook default. + + + Bot Username + + + Icon Emoji + + + Avatar URL + + + Blob Name / Path + + + Leave blank for auto-generated path. + + + Folder ID + + + "0" = root folder. + + + Target Path + + + Write Mode + + + Overwrite + + + Add (rename on conflict) + + + Update (fail if not exists) + + + Credential + + + (none) + + + No matching credentials found for this action type. + + + Add one now → + + + SMTP credentials to send email from. + + + Twilio credentials for SMS delivery. + + + Optional HTTP auth credential. + + + FTP server credentials. + + + SFTP server credentials. + + + AWS S3 credentials (access key + bucket). + + + Microsoft Teams incoming webhook credential. + + + Slack webhook or bot token credential. + + + Discord webhook or bot token credential. + + + Azure Blob Storage connection string credential. + + + Box application credential (JWT). + + + Dropbox access token credential. + + + — select credential — + + + (none / unauthenticated) + + + Condition Expression + + + (optional) + + + Leave blank to always run this step. When set, the step only runs if the condition evaluates to {0}. Uses the same variables as the output template. + + + Visual Builder + + + Raw Expression + + + Add Rule + + + Multiple rules are joined with AND — all must be true. + + + Test Condition + + + contains + + + does not contain + + + equals + + + does not equal + + + starts with + + + ends with + + + is empty + + + is not empty + + + value + + + The current expression is too complex for the visual builder. Edit it in the Raw Expression tab. + + + No condition — step will always run. + + + Testing… + + + Evaluated: "{0}" — step will run + + + Evaluated: "{0}" — step will be skipped + + + Syntax valid (no sample payload to evaluate against) + + + Test request failed. + + + Use Scriban template syntax. Click variables in the panel on the right to insert them. + + + Click a {0} variable to insert it. + + + Click an {0} variable to insert a {1} loop snippet. + + + scalar + + + array + + + Call Collections (arrays) + + + Click to insert a {0} loop. Available for CallAdded, CallUpdated, CallClosed triggers. + + + Fields: {0} + + + Unit / Personnel + + + Failed to save step. + + + Delete failed: {0} + + + e.g. New Call: {0} + + + Resgrid Notification + + + Resgrid Bot + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx index 8a487f5c6..8acb5e003 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.sv.resx @@ -60,115 +60,115 @@ - Workflows + Arbetsflöden - Department Workflows + Avdelningens arbetsflöden - New Workflow + Nytt arbetsflöde - Credentials + Autentiseringsuppgifter - Pending Runs + Väntande körningar - No workflows have been created yet. + Inga arbetsflöden har skapats ännu. - Create your first workflow. + Skapa ditt första arbetsflöde. Namn - Trigger Event + Utlösande händelse Status - Created + Skapad Åtgärder - Enabled + Aktiverat - Disabled + Inaktiverat Redigera - Runs + Körningar - Health + Hälsa Ta bort - Delete workflow {0}? + Ta bort arbetsflödet {0}? - New Workflow + Nytt arbetsflöde - Create Workflow + Skapa arbetsflöde Beskrivning - Max Retry Count + Maximalt antal omförsök - Retry Backoff Base (seconds) + Basfördröjning för omförsök (sekunder) - e.g. Notify Slack on New Call + t.ex. Meddela Slack vid nytt larm - Optional description + Valfri beskrivning Avbryt - Edit Workflow + Redigera arbetsflöde - Workflow Settings + Arbetsflödesinställningar - Max Retries + Max antal omförsök - Backoff (s) + Fördröjning (s) - Save Changes + Spara ändringar - Steps + Steg - Add Step + Lägg till steg - Action Type + Åtgärdstyp - Beställning + Ordning - Template (first 120 chars) + Mall (första 120 tecknen) Ja @@ -177,67 +177,67 @@ Nej - Are you sure you want to delete this step? This action cannot be undone. + Är du säker på att du vill ta bort det här steget? Det går inte att ångra. - Delete Step + Ta bort steg - Template Variables + Mallvariabler - Common variables (always available): + Gemensamma variabler (alltid tillgängliga): - Call event variables: + Variabler för larmhändelser: - Workflow Credentials + Autentiseringsuppgifter för arbetsflöden - Stored Credentials + Sparade autentiseringsuppgifter - Add Credential + Lägg till autentiseringsuppgift - No credentials configured. Add credentials to use with workflow action steps. + Inga autentiseringsuppgifter har konfigurerats. Lägg till autentiseringsuppgifter som kan användas i arbetsflödenas åtgärdssteg. Typ - Secret Data + Hemliga uppgifter - Delete credential {0}? + Ta bort autentiseringsuppgiften {0}? - Please correct the following errors before saving. + Rätta följande fel innan du sparar. - Add Credential + Lägg till autentiseringsuppgift - Edit Credential + Redigera autentiseringsuppgift - Credential Details + Detaljer om autentiseringsuppgiften Typ - Save Credential + Spara autentiseringsuppgift - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + Befintliga hemligheter lagras krypterade och kan inte visas. Fyll i fälten nedan för att ersätta dem, eller lämna alla fält tomma för att behålla de nuvarande hemligheterna. - SMTP Settings + SMTP-inställningar - Host + Värd Port @@ -249,34 +249,34 @@ Lösenord - Use SSL/TLS + Använd SSL/TLS - From Address + Avsändaradress - The email address that will appear in the "From" field of sent messages. + E-postadressen som visas i fältet "Från" i skickade meddelanden. - Twilio Settings + Twilio-inställningar - Account SID + Konto-SID - Auth Token + Autentiseringstoken - From Number + Avsändarnummer - E.164 format, e.g. +15551234567 + E.164-format, t.ex. +15551234567 - FTP Settings + FTP-inställningar - Host + Värd Port @@ -288,13 +288,13 @@ Lösenord - Use Passive Mode + Använd passivt läge - SFTP Settings + SFTP-inställningar - Host + Värd Port @@ -306,37 +306,37 @@ Lösenord - Private Key (PEM) + Privat nyckel (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Ange antingen ett lösenord eller en privat nyckel (eller båda om nyckeln är skyddad med en lösenfras). - AWS S3 Settings + AWS S3-inställningar - Access Key ID + Åtkomstnyckel-ID - Secret Access Key + Hemlig åtkomstnyckel Region - Bucket Name + Bucket-namn - HTTP Bearer Token + HTTP Bearer-token - Bearer Token + Bearer-token - Sent as Authorization: Bearer <token> + Skickas som Authorization: Bearer <token> - HTTP Basic Auth + HTTP Basic-autentisering Användarnamn @@ -345,148 +345,148 @@ Lösenord - HTTP API Key + HTTP API-nyckel - Header Name + Huvudnamn - The HTTP header name that carries the API key. + Namnet på det HTTP-huvud som innehåller API-nyckeln. - API Key + API-nyckel - Microsoft Teams Webhook + Microsoft Teams-webhook - Incoming Webhook URL + URL för inkommande webhook - Create an Incoming Webhook connector inside the desired Teams channel. + Skapa en anslutning för inkommande webhook i önskad Teams-kanal. Slack - Incoming Webhook URL + URL för inkommande webhook - Bot Token + Bot-token - Provide a webhook URL, a bot token, or both. + Ange en webhook-URL, en bot-token eller båda. Discord - Webhook URL + Webhook-URL - Bot Token + Bot-token - Provide a webhook URL, a bot token, or both. + Ange en webhook-URL, en bot-token eller båda. Azure Blob Storage - Connection String + Anslutningssträng - Container Name + Containernamn Box - Client ID + Klient-ID - Client Secret + Klienthemlighet - Enterprise ID + Företags-ID - Public Key ID + ID för publik nyckel - Private Key (PEM) + Privat nyckel (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + Den privata RSA-nyckeln från konfigurationen av din JWT-applikation i Box (PEM-format). - Private Key Passphrase + Lösenfras för privat nyckel - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Valfri lösenfras som används för att dekryptera den privata nyckeln, om den är skyddad med en lösenfras. Dropbox - Refresh Token + Uppdateringstoken - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Långlivad OAuth2-uppdateringstoken som hämtats via OAuth2-auktoriseringsflödet i Dropbox. - App Key + Appnyckel - App Secret + Apphemlighet - Pending / Running Workflows + Väntande / pågående arbetsflöden - Active Runs + Aktiva körningar - Clear All Pending + Rensa alla väntande - Cancel ALL pending runs for this department? + Avbryt ALLA väntande körningar för den här avdelningen? - No pending or running workflow runs. + Inga väntande eller pågående körningar av arbetsflöden. - Run ID + Körnings-ID - Workflow + Arbetsflöde - Queued + Köad - Attempt + Försök Avbryt - Cancel this run? + Avbryt den här körningen? - Workflow Runs + Arbetsflödeskörningar - Run History + Körningshistorik - No runs found for this workflow. + Inga körningar hittades för det här arbetsflödet. - Started + Startad - Completed + Slutförd Fel @@ -498,46 +498,46 @@ Nästa - Workflow Health + Arbetsflödets hälsa - No health data available for this workflow yet. + Det finns inga hälsodata för det här arbetsflödet ännu. - Last 24 Hours + Senaste 24 timmarna - Last 7 Days + Senaste 7 dagarna - Last 30 Days + Senaste 30 dagarna - Successful + Lyckade - Failed + Misslyckade - Total + Totalt - Success rate + Andel lyckade - Performance + Prestanda - Avg Duration + Genomsnittlig varaktighet - Last Run + Senaste körning - Last Error + Senaste fel - View Runs + Visa körningar Aldrig @@ -726,4 +726,325 @@ När ett larm avslutas skickas ett MDM^T02-dokument "Crisis Field Response" till en integrationsmotor via HTTPS: PID-3 är journalsystemets klient-id, MSH-10 idempotensnyckeln och en OBX per frisläppt anpassat fält. Lyckas endast vid kvittensen AA. + + Utdatamall + + + Fältet {0} är obligatoriskt. + + + Den här åtgärdstypen kräver autentiseringsuppgifter. + + + Skicka e-post + + + Skicka SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Ladda upp via FTP + + + Ladda upp via SFTP + + + Ladda upp till S3 + + + Teams-meddelande + + + Slack-meddelande + + + Discord-meddelande + + + Ladda upp till Azure Blob + + + Ladda upp till Box + + + Ladda upp till Dropbox + + + Villkorat + + + Typen av utlösande händelse kan inte ändras efter att arbetsflödet har skapats. + + + Till + + + Kommaseparerade e-postadresser. + + + Kopia + + + valfritt + + + Ämne + + + Till (telefon) + + + E.164-format. Kräver en autentiseringsuppgift för Twilio. + + + URL + + + Tidsgräns (sekunder) + + + Fjärrsökväg + + + Filnamn + + + Lämna tomt för ett automatiskt genererat namn. + + + S3-nyckel (sökväg) + + + Fullständig nyckel/sökväg i bucketen. + + + Korttitel + + + Temafärg + + + Hexkod utan # + + + Kanal + + + Lämna tomt för att använda webhookens standardkanal. + + + Botens användarnamn + + + Ikon-emoji + + + Avatar-URL + + + Blobnamn / sökväg + + + Lämna tomt för en automatiskt genererad sökväg. + + + Mapp-ID + + + "0" = rotmappen. + + + Målsökväg + + + Skrivläge + + + Skriv över + + + Lägg till (byt namn vid konflikt) + + + Uppdatera (misslyckas om filen inte finns) + + + Autentiseringsuppgift + + + (ingen) + + + Inga matchande autentiseringsuppgifter hittades för den här åtgärdstypen. + + + Lägg till en nu → + + + SMTP-autentiseringsuppgifter för att skicka e-post. + + + Twilio-autentiseringsuppgifter för SMS-leverans. + + + Valfri autentiseringsuppgift för HTTP-autentisering. + + + Autentiseringsuppgifter för FTP-servern. + + + Autentiseringsuppgifter för SFTP-servern. + + + Autentiseringsuppgifter för AWS S3 (åtkomstnyckel + bucket). + + + Autentiseringsuppgift för inkommande webhook i Microsoft Teams. + + + Autentiseringsuppgift med Slack-webhook eller bot-token. + + + Autentiseringsuppgift med Discord-webhook eller bot-token. + + + Autentiseringsuppgift med anslutningssträng för Azure Blob Storage. + + + Autentiseringsuppgift för Box-applikation (JWT). + + + Autentiseringsuppgift med åtkomsttoken för Dropbox. + + + — välj autentiseringsuppgift — + + + (ingen / utan autentisering) + + + Villkorsuttryck + + + (valfritt) + + + Lämna tomt för att alltid köra det här steget. Om ett villkor anges körs steget bara när villkoret utvärderas till {0}. Använder samma variabler som utdatamallen. + + + Visuell redigerare + + + Råuttryck + + + Lägg till regel + + + Flera regler kombineras med AND — alla måste vara uppfyllda. + + + Testa villkor + + + innehåller + + + innehåller inte + + + är lika med + + + är inte lika med + + + börjar med + + + slutar med + + + är tom + + + är inte tom + + + värde + + + Det aktuella uttrycket är för komplext för den visuella redigeraren. Redigera det på fliken Råuttryck. + + + Inget villkor — steget körs alltid. + + + Testar… + + + Utvärderat: "{0}" — steget körs + + + Utvärderat: "{0}" — steget hoppas över + + + Syntaxen är giltig (ingen exempelnyttolast att utvärdera mot) + + + Testbegäran misslyckades. + + + Använd Scriban-mallsyntax. Klicka på variablerna i panelen till höger för att infoga dem. + + + Klicka på en variabel av typen {0} för att infoga den. + + + Klicka på en variabel av typen {0} för att infoga ett kodavsnitt med en {1}-loop. + + + skalär + + + array + + + Larmsamlingar (arrayer) + + + Klicka för att infoga en {0}-loop. Tillgängligt för utlösarna CallAdded, CallUpdated och CallClosed. + + + Fält: {0} + + + Enhet / personal + + + Det gick inte att spara steget. + + + Borttagningen misslyckades: {0} + + + t.ex. Nytt larm: {0} + + + Resgrid-avisering + + + Resgrid-bot + diff --git a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx index 7b99a9b69..f941b950f 100644 --- a/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workflows/Workflows.uk.resx @@ -60,115 +60,115 @@ - Workflows + Робочі процеси - Department Workflows + Робочі процеси підрозділу - New Workflow + Новий робочий процес - Credentials + Облікові дані - Pending Runs + Запуски в очікуванні - No workflows have been created yet. + Ще не створено жодного робочого процесу. - Create your first workflow. + Створіть свій перший робочий процес. Ім'я - Trigger Event + Подія-тригер Статус - Created + Створено Дії - Enabled + Увімкнено - Disabled + Вимкнено Редагувати - Runs + Запуски - Health + Стан Видалити - Delete workflow {0}? + Видалити робочий процес {0}? - New Workflow + Новий робочий процес - Create Workflow + Створити робочий процес Опис - Max Retry Count + Максимальна кількість повторних спроб - Retry Backoff Base (seconds) + Базова затримка між повторами (секунди) - e.g. Notify Slack on New Call + Наприклад, сповіщати Slack про новий виклик - Optional description + Необов'язковий опис Скасувати - Edit Workflow + Редагувати робочий процес - Workflow Settings + Налаштування робочого процесу - Max Retries + Макс. повторів - Backoff (s) + Затримка (с) - Save Changes + Зберегти зміни - Steps + Кроки - Add Step + Додати крок - Action Type + Тип дії - Замовлення + Порядок - Template (first 120 chars) + Шаблон (перші 120 символів) Так @@ -177,70 +177,70 @@ Ні - Are you sure you want to delete this step? This action cannot be undone. + Ви впевнені, що хочете видалити цей крок? Цю дію не можна скасувати. - Delete Step + Видалити крок - Template Variables + Змінні шаблону - Common variables (always available): + Загальні змінні (доступні завжди): - Call event variables: + Змінні подій виклику: - Workflow Credentials + Облікові дані робочих процесів - Stored Credentials + Збережені облікові дані - Add Credential + Додати облікові дані - No credentials configured. Add credentials to use with workflow action steps. + Облікові дані не налаштовано. Додайте облікові дані, щоб використовувати їх у кроках дій робочих процесів. Тип - Secret Data + Секретні дані - Delete credential {0}? + Видалити облікові дані {0}? - Please correct the following errors before saving. + Виправте наведені нижче помилки перед збереженням. - Add Credential + Додати облікові дані - Edit Credential + Редагувати облікові дані - Credential Details + Відомості про облікові дані Тип - Save Credential + Зберегти облікові дані - Existing secrets are stored encrypted and cannot be displayed. Fill in the fields below to replace them, or leave all fields blank to keep the current secrets. + Наявні секрети зберігаються в зашифрованому вигляді й не можуть бути відображені. Заповніть поля нижче, щоб замінити їх, або залиште всі поля порожніми, щоб зберегти поточні секрети. - SMTP Settings + Налаштування SMTP - Host + Хост - Port + Порт Ім'я користувача @@ -249,37 +249,37 @@ Пароль - Use SSL/TLS + Використовувати SSL/TLS - From Address + Адреса відправника - The email address that will appear in the "From" field of sent messages. + Адреса електронної пошти, яка відображатиметься в полі «Від» надісланих повідомлень. - Twilio Settings + Налаштування Twilio - Account SID + SID облікового запису - Auth Token + Токен автентифікації - From Number + Номер відправника - E.164 format, e.g. +15551234567 + Формат E.164, наприклад +15551234567 - FTP Settings + Налаштування FTP - Host + Хост - Port + Порт Ім'я користувача @@ -288,16 +288,16 @@ Пароль - Use Passive Mode + Використовувати пасивний режим - SFTP Settings + Налаштування SFTP - Host + Хост - Port + Порт Ім'я користувача @@ -306,37 +306,37 @@ Пароль - Private Key (PEM) + Приватний ключ (PEM) - Provide either a password or a private key (or both if the key is passphrase-protected). + Укажіть пароль або приватний ключ (або обидва, якщо ключ захищено парольною фразою). - AWS S3 Settings + Налаштування AWS S3 - Access Key ID + Ідентифікатор ключа доступу - Secret Access Key + Секретний ключ доступу - Region + Регіон - Bucket Name + Назва бакета - HTTP Bearer Token + Токен HTTP Bearer - Bearer Token + Токен Bearer - Sent as Authorization: Bearer <token> + Надсилається як Authorization: Bearer <token> - HTTP Basic Auth + Базова автентифікація HTTP Ім'я користувача @@ -345,148 +345,148 @@ Пароль - HTTP API Key + Ключ API для HTTP - Header Name + Назва заголовка - The HTTP header name that carries the API key. + Назва заголовка HTTP, який передає ключ API. - API Key + Ключ API - Microsoft Teams Webhook + Вебхук Microsoft Teams - Incoming Webhook URL + URL-адреса вхідного вебхука - Create an Incoming Webhook connector inside the desired Teams channel. + Створіть конектор вхідного вебхука в потрібному каналі Teams. Slack - Incoming Webhook URL + URL-адреса вхідного вебхука - Bot Token + Токен бота - Provide a webhook URL, a bot token, or both. + Укажіть URL-адресу вебхука, токен бота або обидва. Discord - Webhook URL + URL-адреса вебхука - Bot Token + Токен бота - Provide a webhook URL, a bot token, or both. + Укажіть URL-адресу вебхука, токен бота або обидва. Azure Blob Storage - Connection String + Рядок підключення - Container Name + Назва контейнера Box - Client ID + Ідентифікатор клієнта - Client Secret + Секрет клієнта - Enterprise ID + Ідентифікатор підприємства - Public Key ID + Ідентифікатор відкритого ключа - Private Key (PEM) + Приватний ключ (PEM) - The RSA private key from your Box JWT application configuration (PEM format). + Приватний ключ RSA з конфігурації вашого застосунку Box JWT (формат PEM). - Private Key Passphrase + Парольна фраза приватного ключа - Optional passphrase used to decrypt the private key, if it is passphrase-protected. + Необов'язкова парольна фраза для розшифрування приватного ключа, якщо його захищено парольною фразою. Dropbox - Refresh Token + Токен оновлення - Long-lived OAuth2 refresh token obtained from the Dropbox OAuth2 authorization flow. + Довготривалий токен оновлення OAuth2, отриманий під час авторизації OAuth2 у Dropbox. - App Key + Ключ застосунку - App Secret + Секрет застосунку - Pending / Running Workflows + Робочі процеси, що очікують / виконуються - Active Runs + Активні запуски - Clear All Pending + Очистити всі в очікуванні - Cancel ALL pending runs for this department? + Скасувати ВСІ запуски в очікуванні для цього підрозділу? - No pending or running workflow runs. + Немає запусків робочих процесів, що очікують або виконуються. - Run ID + ID запуску - Workflow + Робочий процес - Queued + У черзі - Attempt + Спроба Скасувати - Cancel this run? + Скасувати цей запуск? - Workflow Runs + Запуски робочого процесу - Run History + Історія запусків - No runs found for this workflow. + Для цього робочого процесу не знайдено запусків. - Started + Розпочато - Completed + Завершено Помилка @@ -498,46 +498,46 @@ Наступний - Workflow Health + Стан робочого процесу - No health data available for this workflow yet. + Для цього робочого процесу ще немає даних про стан. - Last 24 Hours + Останні 24 години - Last 7 Days + Останні 7 днів - Last 30 Days + Останні 30 днів - Successful + Успішні - Failed + Невдалі - Total + Усього - Success rate + Рівень успішності - Performance + Продуктивність - Avg Duration + Сер. тривалість - Last Run + Останній запуск - Last Error + Остання помилка - View Runs + Переглянути запуски Ніколи @@ -726,4 +726,325 @@ Після закриття виклику надсилає документ MDM^T02 «Crisis Field Response» до інтеграційного рушія через HTTPS: PID-3 — ідентифікатор клієнта в медичній картці, MSH-10 — ключ ідемпотентності, один OBX для кожного дозволеного користувацького поля. Успішно лише з підтвердженням AA. + + Шаблон виводу + + + Поле {0} є обов'язковим. + + + Для цього типу дії потрібні облікові дані. + + + Надіслати ел. лист + + + Надіслати SMS + + + HTTP GET + + + HTTP POST + + + HTTP PUT + + + HTTP DELETE + + + Завантаження на FTP + + + Завантаження на SFTP + + + Завантаження в S3 + + + Повідомлення Teams + + + Повідомлення Slack + + + Повідомлення Discord + + + Завантаження в Azure Blob + + + Завантаження в Box + + + Завантаження в Dropbox + + + Умовний + + + Тип події-тригера не можна змінити після створення. + + + Кому + + + Адреси електронної пошти через кому. + + + Копія + + + необов'язково + + + Тема + + + Кому (телефон) + + + Формат E.164. Потрібні облікові дані Twilio. + + + URL-адреса + + + Час очікування (секунди) + + + Віддалений шлях + + + Ім'я файлу + + + Залиште порожнім, щоб ім'я створилося автоматично. + + + Ключ S3 (шлях) + + + Повний ключ / шлях у бакеті. + + + Заголовок картки + + + Колір теми + + + Шістнадцятковий код без # + + + Канал + + + Залиште порожнім, щоб використати канал вебхука за замовчуванням. + + + Ім'я користувача бота + + + Емодзі значка + + + URL-адреса аватара + + + Ім'я / шлях blob-об'єкта + + + Залиште порожнім, щоб шлях створився автоматично. + + + ID папки + + + "0" = коренева папка. + + + Цільовий шлях + + + Режим запису + + + Перезаписати + + + Додати (перейменувати в разі конфлікту) + + + Оновити (помилка, якщо файлу немає) + + + Облікові дані + + + (немає) + + + Не знайдено відповідних облікових даних для цього типу дії. + + + Додати зараз → + + + Облікові дані SMTP для надсилання електронної пошти. + + + Облікові дані Twilio для доставки SMS. + + + Необов'язкові облікові дані для автентифікації HTTP. + + + Облікові дані FTP-сервера. + + + Облікові дані SFTP-сервера. + + + Облікові дані AWS S3 (ключ доступу + бакет). + + + Облікові дані вхідного вебхука Microsoft Teams. + + + Облікові дані вебхука або токена бота Slack. + + + Облікові дані вебхука або токена бота Discord. + + + Облікові дані з рядком підключення Azure Blob Storage. + + + Облікові дані застосунку Box (JWT). + + + Облікові дані з токеном доступу Dropbox. + + + — виберіть облікові дані — + + + (немає / без автентифікації) + + + Вираз умови + + + (необов'язково) + + + Залиште порожнім, щоб цей крок виконувався завжди. Якщо умову задано, крок виконується лише тоді, коли вона оцінюється як {0}. Використовуються ті самі змінні, що й у шаблоні виводу. + + + Візуальний конструктор + + + Текстовий вираз + + + Додати правило + + + Кілька правил поєднуються через AND — мають виконуватися всі. + + + Перевірити умову + + + містить + + + не містить + + + дорівнює + + + не дорівнює + + + починається з + + + закінчується на + + + порожнє + + + не порожнє + + + значення + + + Поточний вираз надто складний для візуального конструктора. Відредагуйте його на вкладці «Текстовий вираз». + + + Умови немає — крок виконуватиметься завжди. + + + Перевірка… + + + Оцінено: "{0}" — крок буде виконано + + + Оцінено: "{0}" — крок буде пропущено + + + Синтаксис коректний (немає зразкових даних для оцінювання) + + + Не вдалося виконати тестовий запит. + + + Використовуйте синтаксис шаблонів Scriban. Натискайте змінні на панелі праворуч, щоб вставити їх. + + + Натисніть змінну типу {0}, щоб вставити її. + + + Натисніть змінну типу {0}, щоб вставити фрагмент циклу {1}. + + + скаляр + + + масив + + + Колекції виклику (масиви) + + + Натисніть, щоб вставити цикл {0}. Доступно для тригерів CallAdded, CallUpdated, CallClosed. + + + Поля: {0} + + + Одиниця / персонал + + + Не вдалося зберегти крок. + + + Не вдалося видалити: {0} + + + напр. Новий виклик: {0} + + + Сповіщення Resgrid + + + Бот Resgrid + diff --git a/Core/Resgrid.Localization/AssemblyInfo.cs b/Core/Resgrid.Localization/AssemblyInfo.cs new file mode 100644 index 000000000..fe2b24e67 --- /dev/null +++ b/Core/Resgrid.Localization/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Resources; + +// English lives in the en satellite (X.en.resx), and most bases have no neutral X.resx. Without this, +// a lookup that falls through to the invariant culture (the API host and workers never set a request +// culture) finds nothing and IStringLocalizer returns the key name. With Satellite as the location, the +// invariant culture resolves from en and main-assembly neutral X.resx files are never read, so every +// string must be in X.en.resx. Guarded by Tests/Resgrid.Tests/Localization/NeutralResourcesFallbackTests. +[assembly: NeutralResourcesLanguage("en", UltimateResourceFallbackLocation.Satellite)] diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index a99fce67b..8ef6ff499 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -326,4 +326,43 @@ مخطط JSON (أسماء الحقول وأنواعها) + + استخدام مفتاح مرور + + + الموافقة عبر Responder + + + استخدام رمز المصادقة + + + افتح Resgrid Responder على هاتفك، وتحقق من الطلب، وأدخل هذا الرقم: + + + بانتظار موافقتك في Responder… + + + تم رفض الطلب في Responder. + + + انتهت صلاحية الطلب قبل الموافقة عليه. ابدأ من جديد. + + + تم إغلاق نافذة مفتاح المرور. لم يتغير شيء. + + + تعذر استخدام مفتاح المرور. حاول مرة أخرى أو استخدم تطبيق المصادقة. + + + استخدام موفر الهوية + + + اسمح بالنوافذ المنبثقة لهذا الموقع للتحقق عبر موفر الهوية. + + + نقطة اهتمام + + + تعديل + diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 5e720fe16..ec091957f 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Aktion @@ -759,4 +750,43 @@ JSON-Schema (Feldnamen und Datentypen) + + Passkey verwenden + + + Mit Responder bestätigen + + + Authenticator-Code verwenden + + + Öffnen Sie Resgrid Responder auf Ihrem Telefon, prüfen Sie die Anfrage und geben Sie diese Nummer ein: + + + Warten auf Ihre Bestätigung in Responder… + + + Die Anfrage wurde in Responder abgelehnt. + + + Die Anfrage ist abgelaufen, bevor sie bestätigt wurde. Beginnen Sie erneut. + + + Die Passkey-Abfrage wurde geschlossen. Es wurde nichts geändert. + + + Der Passkey konnte nicht verwendet werden. Versuchen Sie es erneut oder verwenden Sie Ihre Authenticator-App. + + + Identitätsanbieter verwenden + + + Erlauben Sie Pop-ups für diese Website, um sich bei Ihrem Identitätsanbieter zu bestätigen. + + + POI + + + Bearbeiten + diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 76b02fcb4..57ff22e54 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -811,4 +811,40 @@ Σχήμα JSON (ονόματα και τύποι πεδίων) + + Χρήση κλειδιού πρόσβασης + + + Έγκριση με το Responder + + + Χρήση κωδικού ελέγχου ταυτότητας + + + Ανοίξτε το Resgrid Responder στο τηλέφωνό σας, ελέγξτε το αίτημα και πληκτρολογήστε αυτόν τον αριθμό: + + + Αναμονή για την έγκρισή σας στο Responder… + + + Το αίτημα απορρίφθηκε στο Responder. + + + Το αίτημα έληξε πριν εγκριθεί. Ξεκινήστε ξανά. + + + Το παράθυρο του κλειδιού πρόσβασης έκλεισε. Δεν άλλαξε τίποτα. + + + Δεν ήταν δυνατή η χρήση του κλειδιού πρόσβασης. Δοκιμάστε ξανά ή χρησιμοποιήστε την εφαρμογή ελέγχου ταυτότητας. + + + Χρήση του παρόχου ταυτότητας + + + Επιτρέψτε τα αναδυόμενα παράθυρα για αυτόν τον ιστότοπο για επαλήθευση με τον πάροχο ταυτότητας. + + + Επεξεργασία + diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index a5be0fd54..a5695b8cc 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -811,4 +811,40 @@ JSON schema (field names and types) + + Use a passkey + + + Approve with Responder + + + Use an authenticator code + + + Open Resgrid Responder on your phone, check the request, and enter this number: + + + Waiting for your approval in Responder… + + + The request was denied in Responder. + + + The request expired before it was approved. Start again. + + + The passkey prompt was closed. Nothing was changed. + + + The passkey could not be used. Try again, or use your authenticator app. + + + Use your identity provider + + + Allow pop-ups for this site to verify with your identity provider. + + + Edit + diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 5e33f0f00..e128a9f14 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -121,7 +121,7 @@ Acción - Comportamiento + Acciones Activar @@ -138,6 +138,9 @@ Archivos adjuntos + + Audio y pulsar para hablar + Disponible @@ -145,10 +148,10 @@ Disponible en - Estación disponible + Disponible en estación - tablero grande + Big Board Blog @@ -157,10 +160,10 @@ Calendario - Llamar + Llamada - Dirección de llamada + Dirección de la llamada Configuración de importación de llamadas @@ -169,7 +172,7 @@ Llamadas - Marca de tiempo de la llamada + Fecha y hora de la llamada Cancelar @@ -216,6 +219,9 @@ Contáctenos + + Creado el + Estados personalizados @@ -246,14 +252,17 @@ Configuración del departamento + + Tipos de departamento + Descripción - Desechar + Descartar - Enviado + Despachado Listas de distribución @@ -271,19 +280,19 @@ Fin - eta + ETA Evento - Venció + Caducado Exportar - archivos + Archivos Formularios @@ -301,10 +310,10 @@ Ayuda - Servicio de asistencia + Ayuda y soporte - Hogar + Inicio Hora @@ -313,7 +322,7 @@ Horas - Identificación + ID Imágenes @@ -322,10 +331,10 @@ Información - en cuartos + En el cuartel - instructores + Instructores Inventario @@ -346,7 +355,7 @@ Cerrar sesión - Registros + Bitácoras Buzón @@ -391,10 +400,10 @@ Nota - notas + Notas - notas + Notas Notificaciones @@ -406,10 +415,10 @@ Número - En escena + En el lugar - En el turno + De turno Opcional @@ -430,7 +439,7 @@ Personal - Avance + Vista previa Imprimir @@ -439,7 +448,7 @@ Prioridad - Proceso + Procesar Perfil @@ -447,6 +456,9 @@ Protocolos + + PDI + Pulsar para hablar @@ -466,7 +478,7 @@ Requerido - respondiendo + Respondiendo Respondiendo a @@ -478,7 +490,7 @@ Responder a una estación - roles + Roles Ruta @@ -487,10 +499,10 @@ Sábado - Ahorrar + Guardar - Ahorro + Guardando Seguridad y permisos @@ -502,13 +514,13 @@ Turnos - Inscribirse + Registrarse Tamaño - dotación de personal + Dotación de personal En espera @@ -517,13 +529,13 @@ En espera en la estación - Comenzar + Inicio Estación - Estaciones y Grupos + Estaciones y grupos Estado @@ -532,10 +544,10 @@ Estado - + Estado del sistema - Formulario de nueva convocatoria + Formulario de nueva llamada Suscripción y facturación @@ -553,13 +565,13 @@ Texto - Mensaje de texto + Mensajería de texto Jueves - marca de tiempo + Fecha y hora Título @@ -568,13 +580,13 @@ Capacitación - Curso\Código de entrenamiento + Curso\Código de capacitación - Curso\Formación + Curso\Capacitación - Entrenamientos + Capacitaciones Martes @@ -586,7 +598,7 @@ Tipos - Indisponible + No disponible Infundado @@ -610,7 +622,7 @@ Subido por - Subido en + Subido el Usuarios @@ -622,10 +634,10 @@ Video - Vídeos + Videos - Vista + Ver Ver todas las llamadas @@ -643,13 +655,13 @@ Semana - Semanalmente + Semanal Semanas - Registro de trabajo + Bitácora de trabajo Anual @@ -658,10 +670,10 @@ Sí - User Defined Fields + Campos definidos por el usuario - Tus Departamentos + Sus departamentos Llamadas @@ -703,7 +715,7 @@ Cola de moderación protegida - Registros de unidad protegidos + Bitácoras de unidad protegidas cifrado en reposo para este departamento. Los usuarios autorizados y los canales aprobados aún pueden divulgarlo. @@ -769,21 +781,21 @@ Perfil del departamento - Retenciones de conservación de registros + Retenciones legales Todos los registros Cuentas de mensajería Generar código de vinculación - Genera un código y envía LINK seguido del código en una conversación privada con el bot de Resgrid. + Genere un código y envíe LINK seguido del código en una conversación privada con el bot de Resgrid. Código de vinculación Caduca (UTC) Desvincular Cuentas vinculadas Plataformas de mensajería No hay cuentas de mensajería vinculadas. - No se pudieron actualizar las cuentas de mensajería. Inténtalo de nuevo. + No se pudieron actualizar las cuentas de mensajería. Inténtelo de nuevo. Volver al perfil - Tu código de vinculación está listo. - En Discord, usa /resgrid e introduce LINK seguido de tu código en la opción message. Usa /resgrid también para los comandos siguientes. + Su código de vinculación está listo. + En Discord, use /resgrid e introduzca LINK seguido de su código en la opción message. Use /resgrid también para los comandos siguientes. Personal y operaciones Preparación Panel @@ -799,4 +811,40 @@ Esquema JSON (nombres y tipos de campos) + + Usar una clave de acceso + + + Aprobar con Responder + + + Usar un código de autenticación + + + Abra Resgrid Responder en su teléfono, revise la solicitud e introduzca este número: + + + Esperando su aprobación en Responder… + + + La solicitud se rechazó en Responder. + + + La solicitud caducó antes de aprobarse. Vuelva a empezar. + + + Se cerró la solicitud de la clave de acceso. No se cambió nada. + + + No se pudo usar la clave de acceso. Inténtelo de nuevo o use su aplicación de autenticación. + + + Usar su proveedor de identidad + + + Permita las ventanas emergentes de este sitio para verificar con su proveedor de identidad. + + + Editar + diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 1cc58c632..7feab3be8 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Action @@ -759,4 +750,43 @@ Schéma JSON (noms et types des champs) + + Utiliser une clé d'accès + + + Approuver avec Responder + + + Utiliser un code d'authentification + + + Ouvrez Resgrid Responder sur votre téléphone, vérifiez la demande et saisissez ce numéro : + + + En attente de votre approbation dans Responder… + + + La demande a été refusée dans Responder. + + + La demande a expiré avant d'être approuvée. Recommencez. + + + La fenêtre de la clé d'accès a été fermée. Rien n'a été modifié. + + + La clé d'accès n'a pas pu être utilisée. Réessayez ou utilisez votre application d'authentification. + + + Utiliser votre fournisseur d'identité + + + Autorisez les fenêtres contextuelles pour ce site afin de vérifier avec votre fournisseur d'identité. + + + POI + + + Modifier + diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 4819ab474..91bec751d 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Azione @@ -306,7 +297,7 @@ Esci - Registri + Log Casella di posta @@ -609,7 +600,7 @@ Settimane - Registro di lavoro + Log di lavoro Annuale @@ -663,7 +654,7 @@ Coda di moderazione protetta - Registri unità protetti + Log unità protetti crittografato a riposo per questo dipartimento. Gli utenti autorizzati e i canali approvati possono comunque divulgarlo. @@ -759,4 +750,43 @@ Schema JSON (nomi e tipi dei campi) + + Usa una passkey + + + Approva con Responder + + + Usa un codice di autenticazione + + + Apri Resgrid Responder sul telefono, controlla la richiesta e inserisci questo numero: + + + In attesa della tua approvazione in Responder… + + + La richiesta è stata rifiutata in Responder. + + + La richiesta è scaduta prima dell'approvazione. Ricomincia. + + + La richiesta della passkey è stata chiusa. Non è stato modificato nulla. + + + Non è stato possibile usare la passkey. Riprova o usa la tua app di autenticazione. + + + Usa il tuo provider di identità + + + Consenti i popup per questo sito per verificare con il tuo provider di identità. + + + POI + + + Modifica + diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 727924271..3b63be82b 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Akcja @@ -759,4 +750,43 @@ Schemat JSON (nazwy i typy pól) + + Użyj klucza dostępu + + + Zatwierdź w Responder + + + Użyj kodu uwierzytelniającego + + + Otwórz Resgrid Responder na telefonie, sprawdź prośbę i wpisz ten numer: + + + Oczekiwanie na zatwierdzenie w Responder… + + + Prośba została odrzucona w Responder. + + + Prośba wygasła przed zatwierdzeniem. Zacznij od nowa. + + + Okno klucza dostępu zostało zamknięte. Niczego nie zmieniono. + + + Nie udało się użyć klucza dostępu. Spróbuj ponownie lub użyj aplikacji uwierzytelniającej. + + + Użyj dostawcy tożsamości + + + Zezwól na wyskakujące okna dla tej witryny, aby zweryfikować u dostawcy tożsamości. + + + POI + + + Edytuj + diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 609d93f68..370f6225a 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Åtgärd @@ -759,4 +750,43 @@ JSON-schema (fältnamn och typer) + + Använd en nyckel + + + Godkänn med Responder + + + Använd en autentiseringskod + + + Öppna Resgrid Responder på telefonen, kontrollera begäran och ange det här numret: + + + Väntar på ditt godkännande i Responder… + + + Begäran nekades i Responder. + + + Begäran gick ut innan den godkändes. Börja om. + + + Nyckeldialogen stängdes. Inget ändrades. + + + Nyckeln kunde inte användas. Försök igen eller använd din autentiseringsapp. + + + Använd din identitetsleverantör + + + Tillåt popup-fönster för webbplatsen för att verifiera med din identitetsleverantör. + + + POI + + + Redigera + diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 17fab73df..cab676a30 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -59,15 +59,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - this is my long string - - - [base64 mime encoded serialized .NET Framework object] - - - [base64 mime encoded string representing a byte array form of the .NET Framework object] - Дія @@ -759,4 +750,43 @@ Схема JSON (назви й типи полів) + + Використати ключ доступу + + + Підтвердити в Responder + + + Використати код автентифікації + + + Відкрийте Resgrid Responder на телефоні, перевірте запит і введіть це число: + + + Очікування вашого підтвердження в Responder… + + + Запит відхилено в Responder. + + + Термін дії запиту минув до підтвердження. Почніть знову. + + + Вікно ключа доступу закрито. Нічого не змінено. + + + Не вдалося скористатися ключем доступу. Спробуйте ще раз або скористайтеся застосунком автентифікації. + + + Використати постачальника ідентичності + + + Дозвольте спливні вікна для цього сайту, щоб підтвердити через постачальника ідентичності. + + + POI + + + Редагувати + diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 7461e53ea..3d0d661f9 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -349,6 +349,8 @@ public enum AuditLogTypes AdminAssistDiagnosticAccess, /// AI dispatch department settings saved (enhanced-ai-addon-plan.md §4). Append-only. AiDispatchSettingsUpdated, - AdminAssistPlanAccess + AdminAssistPlanAccess, + /// The department security policy changed, including which second factors it accepts (passkey plan section 10.1). Append-only. + DepartmentSecurityPolicyChanged } } diff --git a/Core/Resgrid.Model/DepartmentSecurityPolicy.cs b/Core/Resgrid.Model/DepartmentSecurityPolicy.cs index 3ba934dad..b2d2e728c 100644 --- a/Core/Resgrid.Model/DepartmentSecurityPolicy.cs +++ b/Core/Resgrid.Model/DepartmentSecurityPolicy.cs @@ -79,6 +79,65 @@ public class DepartmentSecurityPolicy : IEntity [ProtoMember(13)] public DateTime? UpdatedOn { get; set; } + // ── Second-factor methods (passkey plan section 10.1) ──────────────── + // These choose which verification is acceptable; RequireMfa and Require2FAForAdmins still decide whether MFA + // is required. TOTP is always accepted. A department with no policy row behaves as these defaults. + + /// A passkey bound to the requesting app counts as MFA for sign-in, department entry and step-up here. + [ProtoMember(14)] + public bool AllowPasskeysForLoginMfa { get; set; } = true; + + /// A passkey counts for this department's Protected Data Grants, ADP management and protected workflows. + [ProtoMember(15)] + public bool AllowPasskeysForAdp { get; set; } = true; + + /// Provider step-up (plan section 7.8) for the scope of . Needs a tested mapping. + [ProtoMember(16)] + public bool AllowFederatedMfaForLoginMfa { get; set; } + + /// Provider step-up for the scope of . Needs a tested mapping. + [ProtoMember(17)] + public bool AllowFederatedMfaForAdp { get; set; } + + /// Responder approval (plan section 7.9) wherever the row's passkey switch is also on; never security changes or account factors. + [ProtoMember(18)] + public bool AllowResponderApproval { get; set; } = true; + + /// Same-session login evidence may serve an ADP grant instead of another verification (plan section 9.1). + [ProtoMember(19)] + public bool AcceptRecentLoginMfaForAdp { get; set; } = true; + + /// Fresh same-operator unlock evidence may serve an ADP reveal on a shared session. + [ProtoMember(20)] + public bool AcceptRecentUnlockMfaForAdp { get; set; } = true; + + /// + /// Advanced by the server whenever RequireMfa or a sign-in method switch changes, in the same transaction as the + /// change. Never written from this entity: saves leave it alone, and the save service advances it with one guarded + /// statement so concurrent changes cannot lose an increment. + /// + [ProtoMember(21)] + public long MfaPolicyVersion { get; set; } + + // ── Shared vehicle and workstation sessions (passkey plan section 10.5) ───── + // The server's effective policy for shared sessions. An installation may ask for stricter behavior, never looser, and + // a stricter value here applies to sessions already running. + + /// Minutes without operator activity before a shared session locks (1-15). + [ProtoMember(22)] + public int SharedIdleLockMinutes { get; set; } = SharedSessionRules.DefaultIdleLockMinutes; + + /// Hours after sign-in when a shared session ends, whatever the activity (1-24). + [ProtoMember(23)] + public int SharedShiftHours { get; set; } = SharedSessionRules.DefaultShiftHours; + + /// + /// The apps ( flags) whose sessions in this department are always shared. Sessions that do + /// not say which app they are count as required too, so leaving the app header out never relaxes this. + /// + [ProtoMember(24)] + public int SharedModeRequiredApps { get; set; } + // ── IEntity ────────────────────────────────────────────────────────── [NotMapped] @@ -95,7 +154,7 @@ public object IdValue [NotMapped] public IEnumerable IgnoredProperties => - new[] { "IdValue", "IdType", "TableName", "IdName", "Department" }; + new[] { "IdValue", "IdType", "TableName", "IdName", "Department", "MfaPolicyVersion" }; } } diff --git a/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs b/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs index 887cd271a..75edca47b 100644 --- a/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs +++ b/Core/Resgrid.Model/DepartmentSecurityPolicyDecisions.cs @@ -8,6 +8,79 @@ public static class DepartmentSecurityPolicyDecisions { public static bool BlocksPasswordLogin(bool requireSso, bool enabledProvider, bool loginViaSso) => requireSso && enabledProvider && !loginViaSso; public static bool RequiresMfaCompletion(bool requireMfa, bool completed) => requireMfa && !completed; + + /// + /// Whether a change moves the department's sign-in MFA rules, which advances MfaPolicyVersion (passkey plan section + /// 10.1): RequireMfa, the sign-in passkey and provider step-up switches, and Responder approval. + /// + public static bool MfaPolicyChanged(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + before.RequireMfa != after.RequireMfa || + before.AllowPasskeysForLoginMfa != after.AllowPasskeysForLoginMfa || + before.AllowFederatedMfaForLoginMfa != after.AllowFederatedMfaForLoginMfa || + before.AllowResponderApproval != after.AllowResponderApproval; + + /// + /// Whether a change moves which MFA may back a Protected Data Grant, which advances the ADP PolicyEpoch and so revokes + /// existing grants (passkey plan section 10.1). + /// + public static bool AdpMethodPolicyChanged(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + before.AllowPasskeysForAdp != after.AllowPasskeysForAdp || + before.AllowFederatedMfaForAdp != after.AllowFederatedMfaForAdp || + before.AllowResponderApproval != after.AllowResponderApproval || + before.AcceptRecentLoginMfaForAdp != after.AcceptRecentLoginMfaForAdp || + before.AcceptRecentUnlockMfaForAdp != after.AcceptRecentUnlockMfaForAdp; + + /// The MFA rule fields of a policy, for comparing a change against what was stored. + public static DepartmentSecurityPolicy SnapshotMfaRules(DepartmentSecurityPolicy p) => new() + { + DepartmentId = p.DepartmentId, + RequireMfa = p.RequireMfa, + AllowPasskeysForLoginMfa = p.AllowPasskeysForLoginMfa, + AllowPasskeysForAdp = p.AllowPasskeysForAdp, + AllowFederatedMfaForLoginMfa = p.AllowFederatedMfaForLoginMfa, + AllowFederatedMfaForAdp = p.AllowFederatedMfaForAdp, + AllowResponderApproval = p.AllowResponderApproval, + AcceptRecentLoginMfaForAdp = p.AcceptRecentLoginMfaForAdp, + AcceptRecentUnlockMfaForAdp = p.AcceptRecentUnlockMfaForAdp, + MfaPolicyVersion = p.MfaPolicyVersion, + SharedIdleLockMinutes = p.SharedIdleLockMinutes, + SharedShiftHours = p.SharedShiftHours, + SharedModeRequiredApps = p.SharedModeRequiredApps + }; + + /// Whether a change moves the shared-device policy (plan section 10.5), which is the managing member's decision. + public static bool SharedPolicyChanged(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + before.SharedIdleLockMinutes != after.SharedIdleLockMinutes || + before.SharedShiftHours != after.SharedShiftHours || + before.SharedModeRequiredApps != after.SharedModeRequiredApps; + + /// + /// Whether a change requires shared mode for an app that did not need it. Refused while the deployment's shared-mode + /// gate is off; removing a requirement is always allowed. + /// + public static bool AddsSharedRequirement(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + (after.SharedModeRequiredApps & ~before.SharedModeRequiredApps) != 0; + + /// The shared-device values are within the department's ranges and name only known apps. + public static bool SharedPolicyValid(DepartmentSecurityPolicy p) => + p.SharedIdleLockMinutes >= 1 && p.SharedIdleLockMinutes <= SharedSessionRules.MaxIdleLockMinutes && + p.SharedShiftHours >= 1 && p.SharedShiftHours <= SharedSessionRules.MaxShiftHours && + (p.SharedModeRequiredApps & ~(int)(SharedModeApps.Unit | SharedModeApps.Command | SharedModeApps.Dispatch)) == 0; + + /// Whether any second-factor method switch differs (the managing member's decision, plan section 10.1). + public static bool MethodSwitchesChanged(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + before.AllowPasskeysForLoginMfa != after.AllowPasskeysForLoginMfa || + before.AllowFederatedMfaForLoginMfa != after.AllowFederatedMfaForLoginMfa || + AdpMethodPolicyChanged(before, after); + + /// + /// Whether a change turns provider step-up on for sign-in or ADP. That needs a tested mapping, and the change cannot + /// itself be authorized by provider step-up (plan section 7.8). + /// + public static bool EnablesFederatedMfa(DepartmentSecurityPolicy before, DepartmentSecurityPolicy after) => + (!before.AllowFederatedMfaForLoginMfa && after.AllowFederatedMfaForLoginMfa) || + (!before.AllowFederatedMfaForAdp && after.AllowFederatedMfaForAdp); + public static int MinimumPasswordLength(int configured) => Math.Max(8, configured); public static bool PasswordExpired(int days, DateTime? lastSetOn, DateTime nowUtc) => days > 0 && lastSetOn.HasValue && nowUtc > lastSetOn.Value.AddDays(days); diff --git a/Core/Resgrid.Model/DepartmentSsoConfig.cs b/Core/Resgrid.Model/DepartmentSsoConfig.cs index 08db15984..ca7f1ee50 100644 --- a/Core/Resgrid.Model/DepartmentSsoConfig.cs +++ b/Core/Resgrid.Model/DepartmentSsoConfig.cs @@ -61,12 +61,38 @@ public class DepartmentSsoConfig : IEntity [MaxLength(1024)] public string AssertionConsumerServiceUrl { get; set; } + /// + /// The IdP's SAML single sign-on URL (HTTP-Redirect binding) that brokered sign-in sends its AuthnRequest to + /// (passkey plan section 7.7.2). Without it, SAML stays on the legacy IdP-initiated relay. + /// + [MaxLength(1024)] + public string IdpSsoUrl { get; set; } + /// AES-encrypted IdP public certificate (PEM) used to verify SAML assertions. public string EncryptedIdpCertificate { get; set; } /// AES-encrypted SP signing certificate private key (PEM) used to sign SAML requests. public string EncryptedSigningCertificate { get; set; } + // ── Provider step-up (federated MFA, passkey plan section 7.8) ─────────────────── + + /// + /// The validated : what to request from the provider and which returned values + /// count as MFA. Changed only by the managing member with fresh MFA; every change needs a new test. + /// + public string FederatedMfaMappingJson { get; set; } + + /// Server-owned: advances whenever the mapping, issuer, client, entity, SSO URL or certificate changes. + public long FederatedMfaMappingVersion { get; set; } + + /// Server-owned: the mapping version that last passed a test step-up. Effective only when it is the current version. + public long? FederatedMfaTestedVersion { get; set; } + + public DateTime? FederatedMfaTestedOnUtc { get; set; } + + [MaxLength(128)] + public string FederatedMfaTestedByUserId { get; set; } + // ── Claim / attribute mapping ───────────────────────────────────────── /// @@ -128,7 +154,9 @@ public object IdValue [NotMapped] public IEnumerable IgnoredProperties => - new[] { "IdValue", "IdType", "TableName", "IdName", "Department", "DefaultRank" }; + new[] { "IdValue", "IdType", "TableName", "IdName", "Department", "DefaultRank", + // Server-owned provider step-up state, changed only by guarded statements so a stale save cannot restore a test. + "FederatedMfaMappingVersion", "FederatedMfaTestedVersion", "FederatedMfaTestedOnUtc", "FederatedMfaTestedByUserId" }; } } diff --git a/Core/Resgrid.Model/EventingTypes.cs b/Core/Resgrid.Model/EventingTypes.cs index b9d4815a0..4a13a91af 100644 --- a/Core/Resgrid.Model/EventingTypes.cs +++ b/Core/Resgrid.Model/EventingTypes.cs @@ -12,6 +12,9 @@ public enum EventingTypes UnitLocationUpdated = 8, IncidentCommandUpdated = 9, ChatEvent = 10, - ChecklistUpdated = 11 + ChecklistUpdated = 11, + + /// An event for one signed-in session only, such as an approval decision (passkey workbook section 7.4). + SessionEvent = 12 } } diff --git a/Core/Resgrid.Model/ProtectedDataGrant.cs b/Core/Resgrid.Model/ProtectedDataGrant.cs index 75bcff6a8..92551cf0a 100644 --- a/Core/Resgrid.Model/ProtectedDataGrant.cs +++ b/Core/Resgrid.Model/ProtectedDataGrant.cs @@ -49,5 +49,51 @@ public class ProtectedDataGrant /// Absolute UTC expiry (exp) — the step-up window end; never sliding. public DateTime ExpiresOnUtc { get; set; } + + /// + /// Grant contract version (grant_ver): 1 for grants without the claim, 2 for passkey-plan grants (plan section + /// 8.2). A version 2 grant is bound to the caller's session, client and authentication generation, and is usable + /// only after ProtectedGrantBinding confirms that binding against the validated session. + /// + public int Version { get; set; } = 1; + + /// Version 2 only: how the second factor was verified (). + public string MfaMethod { get; set; } + + /// Version 2 only: opaque reference to the credential or evidence behind the grant, for revocation checks. + public string MfaCredentialId { get; set; } + + /// Version 2 only: the credential or evidence state version at issuance. + public long? MfaStateVersion { get; set; } + + /// Version 2 only: the account authentication generation at issuance (auth_gen). + public long? AuthenticationGeneration { get; set; } + + /// Version 2 only: the shared-session lock version at issuance, for shared sessions. + public long? SessionLockVersion { get; set; } + + /// Authentication methods references (amr) as issued. + public IReadOnlyList Amr { get; set; } + } + + /// The mfa_method values of a version 2 grant (plan section 8.2). Nothing else is accepted. + public static class ProtectedDataGrantMfaMethods + { + public const string Totp = "totp"; + + /// A passkey registered to the same client as the grant. + public const string Passkey = "passkey"; + + /// A Responder approval (plan section 7.9). + public const string PasskeyApproval = "passkey_approval"; + + /// A mapped identity-provider step-up (plan section 7.8). + public const string Federated = "federated"; + + /// No second factor: only with step_up_exempt. + public const string None = "none"; + + public static bool IsKnown(string method) => + method == Totp || method == Passkey || method == PasskeyApproval || method == Federated || method == None; } } diff --git a/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs index a019e76e4..bdeda633c 100644 --- a/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs +++ b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs @@ -35,6 +35,37 @@ public class ProtectedDataGrantIssueRequest /// Set when the department exempted the calling client from the step-up prompt. public bool StepUpExempt { get; set; } + + /// + /// Contract version to issue: 1 (default) or 2. Version 2 requires , + /// and a verified (or an exemption), and every + /// reader must accept version 2 before any issuer requests it (plan section 8.2). + /// + public int Version { get; set; } = 1; + + /// Version 2: value; none only when exempt. + public string MfaMethod { get; set; } + + /// Version 2: opaque credential or evidence reference (required for passkey, approval and federated). + public string MfaCredentialId { get; set; } + + /// Version 2: credential or evidence state version (required with ). + public long? MfaStateVersion { get; set; } + + /// Version 2: the account's current authentication generation. + public long? AuthenticationGeneration { get; set; } + + /// Version 2: the shared-session lock version, for shared sessions only. + public long? SessionLockVersion { get; set; } + + /// Version 2: true when the first factor was federated sign-in (amr fed) rather than a password. + public bool FederatedFirstFactor { get; set; } + + /// + /// Version 2: an absolute limit the grant may not outlive, such as the session's or shift's end (plan section 9.2). + /// The expiry is the earliest of this, the verification plus the window, and issuance plus the window. + /// + public DateTime? NotAfterUtc { get; set; } } /// Result of a successful grant issuance. The token is sensitive-in-transit but value-free. @@ -75,6 +106,9 @@ public enum ProtectedDataGrantValidationOutcome EpochRevoked = 5, /// The grant does not carry the scope the operation requires. - MissingScope = 6 + MissingScope = 6, + + /// The grant names a contract version this build does not read. + VersionUnsupported = 7 } } diff --git a/Core/Resgrid.Model/Providers/IOidcProviderClient.cs b/Core/Resgrid.Model/Providers/IOidcProviderClient.cs new file mode 100644 index 000000000..1077f79cd --- /dev/null +++ b/Core/Resgrid.Model/Providers/IOidcProviderClient.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Providers +{ + /// + /// The HTTP side of brokered OIDC (passkey plan section 7.7.2): the IdP's discovery document and signing keys, and the + /// authorization-code exchange. Every URL must be https. Validation of what comes back is the caller's job. + /// + public interface IOidcProviderClient + { + /// The IdP's metadata, cached; refetches after a key rotation. + Task GetMetadataAsync(string authority, bool forceRefresh = false, CancellationToken cancellationToken = default); + + /// Posts the code exchange form to the token endpoint and returns the id_token, or the IdP's error. + Task ExchangeCodeAsync(string tokenEndpoint, IReadOnlyDictionary form, + CancellationToken cancellationToken = default); + } + + public sealed class OidcProviderMetadata + { + public string Issuer { get; init; } + public string AuthorizationEndpoint { get; init; } + public string TokenEndpoint { get; init; } + + /// The IdP's JSON Web Key Set, as published. + public string JwksJson { get; init; } + } + + public sealed class OidcCodeExchangeResult + { + public string IdToken { get; init; } + public string Error { get; init; } + } +} diff --git a/Core/Resgrid.Model/Providers/IPasskeyProvider.cs b/Core/Resgrid.Model/Providers/IPasskeyProvider.cs new file mode 100644 index 000000000..aa0b9e3d2 --- /dev/null +++ b/Core/Resgrid.Model/Providers/IPasskeyProvider.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Providers +{ + /// + /// The WebAuthn protocol adapter (passkey plan section 4): builds ceremony options for one client's relying party and + /// verifies the client's response against the exact options that were issued. It is stateless: single use of a + /// challenge, credential ownership and binding are the caller's job (the spike showed the library accepts a replayed + /// assertion and verifies against whatever key it is given). Options and responses cross this boundary as JSON. + /// + public interface IPasskeyProvider + { + /// True when the client has a validated relying party. + bool IsAvailableFor(UserSessionClientApplication client); + + /// + /// Creation options requiring user verification and a discoverable credential, with attestation "none" and the + /// user's existing credentials for this client excluded. asks for a security key or + /// phone, for shared installations (plan section 6.5). + /// + string CreateRegistrationOptions(UserSessionClientApplication client, byte[] userHandle, string userName, string displayName, + IReadOnlyList excludeCredentialIds, bool preferRoaming); + + Task VerifyRegistrationAsync(UserSessionClientApplication client, string optionsJson, + string attestationResponseJson, CancellationToken cancellationToken = default); + + /// Request options requiring user verification, limited to . + string CreateAssertionOptions(UserSessionClientApplication client, IReadOnlyList allowCredentialIds); + + /// The raw credential id an assertion response names, so the caller can load the bound credential; null when unreadable. + byte[] ReadCredentialId(string assertionResponseJson); + + Task VerifyAssertionAsync(UserSessionClientApplication client, string optionsJson, + string assertionResponseJson, PasskeyAssertionCredential credential, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs index 8be790293..f3cb5a2e1 100644 --- a/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs +++ b/Core/Resgrid.Model/Providers/IRabbitInboundEventProvider.cs @@ -29,5 +29,8 @@ void RegisterForEvents(Func personnelStatusChanged, /// void RegisterForChatEvents(Func chatEvent); void RegisterForChecklistEvents(Func checklistEvent); + + /// Events for one session (session id, serialized SessionEventMessage). + void RegisterForSessionEvents(Func sessionEvent); } } diff --git a/Core/Resgrid.Model/Repositories/IAuthenticationChallengeRepository.cs b/Core/Resgrid.Model/Repositories/IAuthenticationChallengeRepository.cs new file mode 100644 index 000000000..88ec32560 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IAuthenticationChallengeRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// Compare-and-set challenge storage (workbook section 8.3). Each call uses its own connection. + public interface IAuthenticationChallengeRepository + { + Task InsertAsync(AuthenticationChallenge challenge, CancellationToken cancellationToken = default); + + Task GetAsync(string challengeId, CancellationToken cancellationToken = default); + + Task CountPendingForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Spends a pending, unexpired challenge with attempts left. True for exactly one caller. + Task TryConsumeAsync(string challengeId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Counts a failed verification; the challenge becomes Exhausted when it reaches its attempt limit. + Task RecordFailedAttemptAsync(string challengeId, CancellationToken cancellationToken = default); + + /// Cancels every pending challenge for the user (credential revoked, MFA changed, sessions revoked). + Task CancelPendingForUserAsync(string userId, CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IBrokerReplayRepository.cs b/Core/Resgrid.Model/Repositories/IBrokerReplayRepository.cs new file mode 100644 index 000000000..6ed6d1198 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IBrokerReplayRepository.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// + /// Shared, single-use record of broker request ids and session-assertion ids (passkey workbook section 6.2). It + /// replaces the per-process replay cache, so a replay is refused by every broker replica. A fault is thrown, and the + /// broker treats it as a refusal. + /// + public interface IBrokerReplayRepository + { + /// + /// Records the key once. True only for the first claim; false when it was already claimed, even if that claim has + /// expired but not yet been purged. + /// + Task TryClaimAsync(string replayKey, BrokerReplayKind kind, DateTime expiresOnUtc, DateTime utcNow, + CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } + + public enum BrokerReplayKind + { + RequestId = 1, + SessionAssertion = 2, + + /// An IdP id_token, recorded until its expiry so it is accepted once (passkey plan section 7.7.2 item 8). + IdToken = 3 + } +} diff --git a/Core/Resgrid.Model/Repositories/IDepartmentSecurityPolicyRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentSecurityPolicyRepository.cs index cc33afa7e..79bc54248 100644 --- a/Core/Resgrid.Model/Repositories/IDepartmentSecurityPolicyRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDepartmentSecurityPolicyRepository.cs @@ -9,6 +9,15 @@ public interface IDepartmentSecurityPolicyRepository : IRepositoryReturns the security policy for a given department, or null if none is configured. Task GetByDepartmentIdAsync(int departmentId); + + /// + /// The stored policy read inside the caller's unit-of-work transaction with an update lock, so two concurrent + /// changes to one department's policy are compared and versioned one after the other. Requires an open transaction. + /// + Task GetByDepartmentIdForUpdateAsync(int departmentId, System.Threading.CancellationToken cancellationToken = default); + + /// Advances MfaPolicyVersion by one in the caller's transaction and returns the new value. + Task IncrementMfaPolicyVersionAsync(int departmentId, System.Threading.CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IDepartmentSsoConfigRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentSsoConfigRepository.cs index e83649168..bddd01a12 100644 --- a/Core/Resgrid.Model/Repositories/IDepartmentSsoConfigRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDepartmentSsoConfigRepository.cs @@ -16,6 +16,16 @@ public interface IDepartmentSsoConfigRepository : IRepositoryReturns the SSO config matching the given SAML EntityId (for SP-initiated SAML lookups). Task GetByEntityIdAsync(string entityId); + + /// + /// Advances the provider step-up mapping version and clears its test result in one statement (passkey plan section + /// 7.8: every change needs a new test). Returns the new version. + /// + Task AdvanceFederatedMfaMappingVersionAsync(string departmentSsoConfigId, System.Threading.CancellationToken cancellationToken = default); + + /// Records a passed test only if the mapping is still at ; false when it changed. + Task TryRecordFederatedMfaTestAsync(string departmentSsoConfigId, long version, string userId, System.DateTime utcNow, + System.Threading.CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Repositories/IFactorRecoveryTransactionRepository.cs b/Core/Resgrid.Model/Repositories/IFactorRecoveryTransactionRepository.cs new file mode 100644 index 000000000..cabf57fe9 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IFactorRecoveryTransactionRepository.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// Restricted factor recovery transactions (passkey plan section 5.4; workbook section 8.3): every state change is one + /// guarded UPDATE whose success is exactly one row, so a recovery completes once however many requests race. + /// + public interface IFactorRecoveryTransactionRepository + { + Task InsertAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default); + + Task GetBySecretHashAsync(byte[] secretHash, CancellationToken cancellationToken = default); + + /// Counts a wrong code for the new authenticator; the last allowed attempt exhausts the transaction. + Task RecordFailedAttemptAsync(string transactionId, CancellationToken cancellationToken = default); + + /// Pending, unexpired and under its attempt limit, to completed. + Task TryCompleteAsync(string transactionId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Pending to canceled. + Task TryCancelAsync(string transactionId, CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + + /// Removes every recovery transaction for an account being deleted. + Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IIdentityUserRepository.cs b/Core/Resgrid.Model/Repositories/IIdentityUserRepository.cs index 5f12c7cf5..0fb4a3194 100644 --- a/Core/Resgrid.Model/Repositories/IIdentityUserRepository.cs +++ b/Core/Resgrid.Model/Repositories/IIdentityUserRepository.cs @@ -37,5 +37,22 @@ public interface IIdentityUserRepository Task GetTokenAsync(string userId, string loginProvider, string name); Task SetTokenAsync(string userId, string loginProvider, string name, string value, CancellationToken cancellationToken); Task RemoveTokenAsync(string userId, string loginProvider, string name, CancellationToken cancellationToken); + + /// + /// Replaces a token's value only if it is still exactly , in one guarded statement, so + /// re-encrypting a seed never overwrites a replacement written meanwhile. Returns true when this call replaced it. + /// + Task TryReplaceTokenAsync(string userId, string loginProvider, string name, string expectedValue, string newValue, + CancellationToken cancellationToken); + + /// One page of a token kind across users, ordered by user id, after (null for the first). + Task> GetTokensPageAsync(string loginProvider, string name, string afterUserId, int take, + CancellationToken cancellationToken); + } + + public sealed class UserTokenValue + { + public string UserId { get; set; } + public string Value { get; set; } } } diff --git a/Core/Resgrid.Model/Repositories/IMfaActivityRepository.cs b/Core/Resgrid.Model/Repositories/IMfaActivityRepository.cs new file mode 100644 index 000000000..0dc6d995e --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IMfaActivityRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// The account's recent verifications (plan section 6.5). + public interface IMfaActivityRepository + { + Task InsertAsync(MfaActivity activity, CancellationToken cancellationToken = default); + + Task CountDeniedSinceAsync(string userId, DateTime sinceUtc, CancellationToken cancellationToken = default); + + /// The user's activity since , newest first, at most . + Task> GetRecentAsync(string userId, DateTime sinceUtc, int take, CancellationToken cancellationToken = default); + + Task GetAsync(string mfaActivityId, CancellationToken cancellationToken = default); + + /// Marks the user's own activity reported, once, in one guarded statement. + Task TryMarkReportedAsync(string mfaActivityId, string userId, DateTime reportedOnUtc, CancellationToken cancellationToken = default); + + Task PurgeBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + + Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IMfaApprovalRequestRepository.cs b/Core/Resgrid.Model/Repositories/IMfaApprovalRequestRepository.cs new file mode 100644 index 000000000..8da400705 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IMfaApprovalRequestRepository.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// Responder approval requests (passkey plan section 5.6; workbook section 8.3). Every transition is one guarded UPDATE + /// on its own connection whose success is exactly one row: a request is approved once and consumed once by the + /// requester that made it, approval never revives an expired or canceled request, and a denial is terminal. + /// + public interface IMfaApprovalRequestRepository + { + /// + /// Inserts the request unless the user already has a pending one; false when another pending request exists (a + /// concurrent request won). Expired pending rows are closed first, so they never block. + /// + Task TryInsertPendingAsync(MfaApprovalRequest request, DateTime utcNow, CancellationToken cancellationToken = default); + + Task GetAsync(string approvalRequestId, CancellationToken cancellationToken = default); + + /// The user's pending, unexpired request, if any. + Task GetPendingForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// The user's most recent requests, newest first, for the suspension rule. + Task> GetRecentForUserAsync(string userId, int take, CancellationToken cancellationToken = default); + + Task CountCreatedSinceAsync(string userId, DateTime sinceUtc, CancellationToken cancellationToken = default); + + /// Counts a wrong number on a pending, unexpired request; the last allowed attempt denies it. Returns the new state, or null when nothing changed. + Task RecordWrongNumberAsync(string approvalRequestId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Pending and unexpired, under its attempt limit, to approved by this Responder session and passkey. + Task TryApproveAsync(string approvalRequestId, string approverSessionId, string approverPasskeyId, DateTime utcNow, + CancellationToken cancellationToken = default); + + /// Pending to denied, with the reason. + Task TryDenyAsync(string approvalRequestId, MfaApprovalEndReason reason, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Pending to canceled, only by the requester that made it. + Task TryCancelAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, DateTime utcNow, + CancellationToken cancellationToken = default); + + /// + /// Approved to consumed, once, by the requester that made it, no later than past the + /// request's expiry (the requester's last poll may land just after it). + /// + Task TryConsumeAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, DateTime utcNow, + TimeSpan graceAfterExpiry, CancellationToken cancellationToken = default); + + /// Cancels the user's pending requests with the reason (a newer request, or the approver revoked); returns how many. + Task CancelPendingForUserAsync(string userId, MfaApprovalEndReason reason, DateTime utcNow, CancellationToken cancellationToken = default); + + Task PurgeCreatedBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IMfaLoginTransactionRepository.cs b/Core/Resgrid.Model/Repositories/IMfaLoginTransactionRepository.cs new file mode 100644 index 000000000..6ad423b47 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IMfaLoginTransactionRepository.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// Restricted login transactions (workbook section 8.3): every state change is one guarded UPDATE whose success is + /// exactly one row, on its own connection, so two nodes can never both complete or redeem one transaction. + /// + public interface IMfaLoginTransactionRepository + { + Task InsertAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default); + + Task GetBySecretHashAsync(byte[] secretHash, CancellationToken cancellationToken = default); + + /// Counts a failed second factor; the transaction is exhausted in the same statement at its limit. + Task RecordFailedAttemptAsync(string transactionId, CancellationToken cancellationToken = default); + + /// + /// Moves a pending, unexpired transaction under its attempt limit to Completed with the verified factor and the + /// completion code's hash. True for exactly one caller. A null method completes a login that needed no second factor. + /// + Task TryCompleteAsync(string transactionId, int? method, string factorReference, DateTime? verifiedOnUtc, bool isRecovery, + byte[] completionCodeHash, DateTime completionExpiresOnUtc, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Redeems a completed transaction whose code matches and has not expired. True for exactly one caller. + Task TryRedeemAsync(string transactionId, byte[] completionCodeHash, DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// Ends a pending or completed-but-unredeemed transaction, as exhausted: the user said they did not start this + /// sign-in (plan section 7.9 "not me"), so nothing more is issued from it. + /// + Task TryAbandonAsync(string transactionId, CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/ISecurityNoticeRepository.cs b/Core/Resgrid.Model/Repositories/ISecurityNoticeRepository.cs new file mode 100644 index 000000000..e0338c837 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ISecurityNoticeRepository.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// The user-level security notice outbox (passkey plan section 6.4; workbook section 8.3 rule 7). A sender claims a + /// notice with a lease in one guarded statement, so two nodes never send the same notice at once, and every result is + /// recorded only by the sender holding the lease. + /// + public interface ISecurityNoticeRepository + { + Task InsertAsync(SecurityNotice notice, CancellationToken cancellationToken = default); + + Task GetAsync(string securityNoticeId, CancellationToken cancellationToken = default); + + /// Claims one pending, due, unleased notice for until . + Task TryClaimAsync(string securityNoticeId, string owner, DateTime utcNow, DateTime leaseUntilUtc, CancellationToken cancellationToken = default); + + /// Claims up to pending, due, unleased notices, skipping rows another sender holds. + Task> ClaimDueAsync(string owner, DateTime utcNow, DateTime leaseUntilUtc, int batchSize, + CancellationToken cancellationToken = default); + + Task MarkSentAsync(string securityNoticeId, string owner, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Counts a failed attempt and schedules the next one, releasing the lease. + Task MarkRetryAsync(string securityNoticeId, string owner, DateTime nextAttemptOnUtc, string failure, CancellationToken cancellationToken = default); + + /// Counts the last failed attempt and ends the notice as failed. + Task MarkFailedAsync(string securityNoticeId, string owner, string failure, CancellationToken cancellationToken = default); + + /// Whether the user was already sent (or is being sent) this kind of notice since . + Task ExistsSinceAsync(string userId, SecurityNoticeKind kind, DateTime sinceUtc, CancellationToken cancellationToken = default); + + /// Removes sent and failed notices created before the cutoff. + Task PurgeFinishedBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + + /// Removes every notice for an account being deleted. + Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/ISsoLoginTransactionRepository.cs b/Core/Resgrid.Model/Repositories/ISsoLoginTransactionRepository.cs new file mode 100644 index 000000000..581503600 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/ISsoLoginTransactionRepository.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// Brokered SSO transactions (workbook section 8.3): every state change is one guarded UPDATE whose success is exactly + /// one row, on its own connection, so an IdP result is accepted once and its code redeemed once across nodes. + /// + public interface ISsoLoginTransactionRepository + { + Task InsertAsync(SsoLoginTransaction transaction, CancellationToken cancellationToken = default); + + Task GetAsync(string transactionId, CancellationToken cancellationToken = default); + + Task GetByStateHashAsync(byte[] stateHash, CancellationToken cancellationToken = default); + + /// + /// Records the validated IdP result, the value a provider step-up mapping counted as MFA (if any) and the code's hash + /// on a pending, unexpired transaction. True once. + /// + Task TryAuthenticateAsync(string transactionId, string userId, DateTime authenticatedOnUtc, string federatedMfaValue, byte[] codeHash, + DateTime codeExpiresOnUtc, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Marks a pending transaction failed, so its state cannot be used again. True once. + Task TryFailAsync(string transactionId, string failureCode, CancellationToken cancellationToken = default); + + /// Redeems an authenticated transaction whose code matches and has not expired. True once. + Task TryRedeemAsync(string transactionId, byte[] codeHash, DateTime utcNow, CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUserMfaStateRepository.cs b/Core/Resgrid.Model/Repositories/IUserMfaStateRepository.cs new file mode 100644 index 000000000..3f406b01a --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUserMfaStateRepository.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// + /// Durable, compare-and-set MFA factor state (passkey plan section 5, workbook section 8). Every consume is one + /// guarded statement whose success is exactly one changed row, so two API nodes can never both accept the same + /// TOTP time step or recovery code. No TOTP seed or plaintext recovery code is stored here. + /// + public interface IUserMfaStateRepository + { + /// + /// Records as the user's last accepted TOTP step when it is newer than the stored one. + /// Returns false when the step (or a later one) was already accepted — a replayed code. + /// + Task TryConsumeTotpTimeStepAsync(string userId, long timeStep, DateTime utcNow, CancellationToken cancellationToken = default); + + Task GetTotpStateAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Stamps when the current authenticator was enrolled or replaced, and whether that happened in a shared vehicle or + /// workstation session (plan section 6.5). The time-step row must already exist. + /// + Task RecordTotpEnrollmentAsync(string userId, DateTime utcNow, TotpEnrollmentContext context, CancellationToken cancellationToken = default); + + Task CountUnusedRecoveryCodesAsync(string userId, CancellationToken cancellationToken = default); + + /// Marks one unused code used. Returns false when no unused code has that hash. + Task TryRedeemRecoveryCodeAsync(string userId, byte[] codeHash, DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// Atomically replaces every recovery code for the user (and deletes any legacy plaintext token). An empty set + /// leaves the user with no recovery codes. + /// + Task ReplaceRecoveryCodesAsync(string userId, IReadOnlyCollection codeHashes, int hashVersion, DateTime utcNow, + CancellationToken cancellationToken = default); + + /// + /// One-time migration of the legacy ";"-joined plaintext token: deletes the token only if it still holds + /// and inserts the hashed codes in the same transaction. Returns false when + /// another request already migrated or changed it. + /// + Task ImportLegacyRecoveryCodesAsync(string userId, string legacyTokenValue, IReadOnlyCollection codeHashes, + int hashVersion, DateTime utcNow, CancellationToken cancellationToken = default); + + /// The user's last successful MFA method (MfaEvidenceMethod value), or null when none is recorded. + Task GetPreferredMethodAsync(string userId, CancellationToken cancellationToken = default); + + /// Records the user's last successful MFA method (plan section 7.5 rule 5). + Task SetPreferredMethodAsync(string userId, int method, DateTime utcNow, CancellationToken cancellationToken = default); + } + + public sealed class UserTotpState + { + public string UserId { get; set; } + public long LastAcceptedTimeStep { get; set; } + public DateTime LastAcceptedOnUtc { get; set; } + public DateTime? EnrolledOnUtc { get; set; } + + /// The current authenticator was set up in a shared session, so its setup key may have been seen there. + public bool EnrolledInSharedMode { get; set; } + + /// The app (UserSessionClientApplication) the current authenticator was set up from. + public int? EnrolledClientApplication { get; set; } + + /// The installation label it was set up on; a label, not proof of a device. + public string EnrolledInstallation { get; set; } + } + + /// Where an authenticator was set up (plan section 6.5). + public sealed record TotpEnrollmentContext(bool SharedMode, int? ClientApplication = null, string Installation = null); +} diff --git a/Core/Resgrid.Model/Repositories/IUserPasskeyRepository.cs b/Core/Resgrid.Model/Repositories/IUserPasskeyRepository.cs new file mode 100644 index 000000000..ce4257180 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUserPasskeyRepository.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + /// + /// Registered passkeys (passkey plan section 5.1; workbook section 8.3 rules): each change is one guarded statement on + /// its own connection, and a credential id is unique within its RP, so concurrent registration can never attach one + /// credential to two users. + /// + public interface IUserPasskeyRepository + { + /// Inserts the credential; false when the RP already holds that credential id. + Task TryInsertAsync(UserPasskey passkey, CancellationToken cancellationToken = default); + + Task GetAsync(string userPasskeyId, CancellationToken cancellationToken = default); + + /// The active credential with this id hash in the RP, or null. The caller compares the full id. + Task GetActiveByCredentialAsync(string rpId, byte[] credentialIdHash, CancellationToken cancellationToken = default); + + /// Every active credential the user holds, in every client. + Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + + Task CountActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// The user handle the user already has at this RP (from any of their credentials, revoked or not), so every + /// credential for one account and RP shares one stable handle; null when there is none yet. + /// + Task GetUserHandleAsync(string userId, string rpId, CancellationToken cancellationToken = default); + + Task TryRenameAsync(string userPasskeyId, string userId, string displayName, CancellationToken cancellationToken = default); + + /// Revokes one active credential the user owns; false when it is not the user's or already revoked. + Task TryRevokeAsync(string userPasskeyId, string userId, PasskeyRevocationReason reason, string actorUserId, DateTime utcNow, + CancellationToken cancellationToken = default); + + /// Revokes every active credential the user holds for one client; returns the revoked row ids. + Task> RevokeAllForClientAsync(string userId, int clientApplication, PasskeyRevocationReason reason, string actorUserId, DateTime utcNow, + CancellationToken cancellationToken = default); + + /// + /// Records a successful assertion only if the stored counter is still and the + /// credential is active; false means another use or a revocation won the race and this assertion is refused. + /// + Task TryRecordUseAsync(string userPasskeyId, long expectedSignCount, long newSignCount, bool isBackedUp, int clientApplication, + string installation, bool sharedMode, DateTime utcNow, CancellationToken cancellationToken = default); + + Task TrySetApprovalEnabledAsync(string userPasskeyId, string userId, bool enabled, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUserSessionMfaEvidenceRepository.cs b/Core/Resgrid.Model/Repositories/IUserSessionMfaEvidenceRepository.cs new file mode 100644 index 000000000..3f6eab4ea --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IUserSessionMfaEvidenceRepository.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Repositories +{ + public interface IUserSessionMfaEvidenceRepository + { + Task InsertAsync(MfaEvidence evidence, CancellationToken cancellationToken = default); + + /// + /// The most recent unrevoked, unexpired evidence of for the session, issued under + /// ; null when there is none. + /// + Task GetLatestAsync(string userId, string sessionKey, MfaEvidenceKind kind, long authenticationGeneration, + DateTime utcNow, CancellationToken cancellationToken = default); + + /// As , restricted to evidence recorded for . + Task GetLatestForPurposeAsync(string userId, string sessionKey, MfaEvidenceKind kind, MfaEvidencePurpose purpose, + long authenticationGeneration, DateTime utcNow, CancellationToken cancellationToken = default); + + Task RevokeForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Revokes the user's evidence produced by one factor instance (for example a passkey that was removed). + Task RevokeForFactorAsync(string userId, string factorReference, DateTime utcNow, CancellationToken cancellationToken = default); + + /// Revokes every user's evidence from one factor, such as a provider step-up mapping version that changed. + Task RevokeByFactorReferenceAsync(string factorReference, DateTime utcNow, CancellationToken cancellationToken = default); + + Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs b/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs index d9bde7edc..b5e398bed 100644 --- a/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs +++ b/Core/Resgrid.Model/Repositories/IUserSessionsRepository.cs @@ -26,5 +26,38 @@ Task UpdateDepartmentAsync(string targetUserId, string sessionId, int depar Task RevokeAllAsync(string targetUserId, string actorUserId, int reason, DateTime revokedOn, CancellationToken cancellationToken); Task RevokeDepartmentAsync(string targetUserId, int departmentId, int reason, DateTime revokedOn, CancellationToken cancellationToken); Task PurgeInactiveBeforeAsync(DateTime historyBeforeUtc, CancellationToken cancellationToken); + + /// + /// Locks an active, unlocked shared session still at and advances its lock + /// version, in one guarded statement (passkey plan section 12.5.3). Returns 1 when this call locked it, 0 when it + /// was already locked, changed, ended, or is not shared. + /// + Task TryLockAsync(string sessionId, long expectedLockVersion, int reason, DateTime lockedOnUtc, CancellationToken cancellationToken); + + /// + /// Unlocks the user's active, unexpired, locked shared session only at , and + /// restarts its idle deadline. The lock version is not advanced, so one lock can be unlocked at most once. Returns 1 + /// when this call unlocked it. + /// + Task TryUnlockAsync(string userId, string sessionId, long expectedLockVersion, DateTime unlockedOnUtc, CancellationToken cancellationToken); + + /// + /// Records operator activity on an active, unlocked shared session, at most once per write interval, and never for a + /// session whose idle deadline already passed (): activity cannot revive a lapsed session. + /// + Task RecordOperatorActivityAsync(string sessionId, DateTime occurredOnUtc, DateTime writeBefore, DateTime idleCutoff, + CancellationToken cancellationToken); + + /// + /// The state columns of the given sessions, in batches (id, state, expiry, creation, and the shared lock and idle + /// fields), for the SignalR connection sweep. Missing sessions are simply absent. + /// + Task> GetStatesAsync(IReadOnlyCollection sessionIds, CancellationToken cancellationToken); + + /// + /// Stops approval requests on the user's active Responder installation , or on all of them + /// when it is null (plan section 6.5). Returns how many installations stopped. + /// + Task DisableApprovalsAsync(string userId, string sessionId, DateTime disabledOnUtc, CancellationToken cancellationToken); } } diff --git a/Core/Resgrid.Model/Search/SearchContracts.cs b/Core/Resgrid.Model/Search/SearchContracts.cs index ee8b28fb5..b8832fd9f 100644 --- a/Core/Resgrid.Model/Search/SearchContracts.cs +++ b/Core/Resgrid.Model/Search/SearchContracts.cs @@ -30,8 +30,23 @@ public static class SearchEntityTypes public const string ServiceContract = "ServiceContract"; public const string Deployment = "Deployment"; public const string CertificationType = "CertificationType"; - - public static readonly IReadOnlyList Indexed = new[] { Call, Unit, Personnel, Contact, Message, Document, Note, Invoice, RateCard, Bid, ServiceContract, Deployment, CertificationType }; + // Operations reference families (plan R3 Tier 2): names, codes and descriptions; legacy logs carry their narrative + // under the same protection rule as call notes. + public const string Protocol = "Protocol"; + public const string Training = "Training"; + public const string CalendarEvent = "CalendarEvent"; + public const string Log = "Log"; + public const string Poi = "Poi"; + public const string Shift = "Shift"; + public const string Group = "Group"; + // RMS occupancy master (pre-plans): name, number, address and the alarm company, under the Records gates. + public const string Occupancy = "Occupancy"; + + public static readonly IReadOnlyList Indexed = new[] + { + Call, Unit, Personnel, Contact, Message, Document, Note, Invoice, RateCard, Bid, ServiceContract, Deployment, CertificationType, + Protocol, Training, CalendarEvent, Log, Poi, Shift, Group, Occupancy + }; } /// Index state values stored on SearchIndexState.State (same numbering as the RMS records index). @@ -50,8 +65,8 @@ public enum SearchIndexBuildState /// public static class GlobalSearchGeneration { - /// Bump when GlobalSearchDocumentBuilder or the projection allowlist changes. - public const int SchemaVersion = 2; + /// Bump when GlobalSearchDocumentBuilder or the projection allowlist changes. 3: call notes in the call full text. + public const int SchemaVersion = 3; public static string Compute(int protectedCatalogVersion, long policyEpoch) { diff --git a/Core/Resgrid.Model/Search/SearchSnippets.cs b/Core/Resgrid.Model/Search/SearchSnippets.cs new file mode 100644 index 000000000..e4f0c5389 --- /dev/null +++ b/Core/Resgrid.Model/Search/SearchSnippets.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Resgrid.Model.Search +{ + /// + /// Plain-text helpers shared by the unified endpoint and the search page: which words of a query to look for in a hit's + /// text, and the excerpt around the first one. Matching is case-insensitive and literal; nothing here is HTML. + /// + public static class SearchSnippets + { + private static readonly Regex QueryParts = new Regex("\"([^\"]*)\"|(\\S+)", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + private static readonly Regex Whitespace = new Regex("\\s+", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + /// + /// The quoted phrases and the single words of a query, longest first so a phrase wins over its own words. Words of one + /// character and bare punctuation are left out; they would light up half of any text. + /// + public static List Terms(string query) + { + var terms = new List(); + if (string.IsNullOrWhiteSpace(query)) + return terms; + + foreach (Match match in QueryParts.Matches(query)) + { + var phrase = match.Groups[1].Success ? Clean(match.Groups[1].Value) : null; + if (!string.IsNullOrEmpty(phrase)) + { + terms.Add(phrase); + terms.AddRange(phrase.Split(' ').Select(Clean)); + continue; + } + + terms.Add(Clean(match.Groups[2].Value)); + } + + return terms + .Where(t => !string.IsNullOrEmpty(t) && t.Length > 1 && t.Any(char.IsLetterOrDigit)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderByDescending(t => t.Length) + .Take(16) + .ToList(); + } + + /// + /// Up to characters of around the first occurrence of the first + /// term (in the given order, so a phrase from wins over its words) that occurs, with an ellipsis + /// where it was cut. Null when the text is empty or none of the terms occur in it. + /// + public static string Build(string text, IReadOnlyList terms, int maxLength = 240) + { + if (string.IsNullOrWhiteSpace(text) || terms == null || terms.Count == 0) + return null; + + var flat = Whitespace.Replace(text, " ").Trim(); + var index = -1; + var length = 0; + foreach (var term in terms) + { + if (string.IsNullOrEmpty(term)) + continue; + var at = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase); + if (at >= 0) + { + index = at; + length = term.Length; + break; + } + } + + if (index < 0) + return null; + + maxLength = Math.Max(40, maxLength); + if (flat.Length <= maxLength) + return flat; + + var start = Math.Max(0, index - (maxLength - length) / 3); + if (start + maxLength > flat.Length) + start = Math.Max(0, flat.Length - maxLength); + // Start and end on a word boundary when one is close. + if (start > 0) + { + var space = flat.IndexOf(' ', start); + if (space >= 0 && space < index && space - start < 20) + start = space + 1; + } + var end = Math.Min(flat.Length, start + maxLength); + if (end < flat.Length) + { + var space = flat.LastIndexOf(' ', end - 1); + if (space > index + length && end - space < 20) + end = space; + } + + return (start > 0 ? "…" : string.Empty) + flat.Substring(start, end - start).Trim() + (end < flat.Length ? "…" : string.Empty); + } + + private static string Clean(string value) => Whitespace.Replace((value ?? string.Empty).Trim().Trim(',', ';', '.', ':', '!', '?', '(', ')', '[', ']'), " "); + } +} diff --git a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs index 7fdf6c574..c83157f91 100644 --- a/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs +++ b/Core/Resgrid.Model/Search/UnifiedSearchContracts.cs @@ -53,6 +53,42 @@ public class UnifiedSearchRequest /// Typeahead: prefix-match the title of every family; short result list, no records federation. public bool Prefix { get; set; } + + /// Only hits that occurred at or after this instant (UTC). + public DateTime? FromUtc { get; set; } + + /// Only hits that occurred at or before this instant (UTC). + public DateTime? ToUtc { get; set; } + + /// One of ; null or unknown means relevance. + public string Sort { get; set; } + + /// + /// How many index candidates may be authorized to serve this request (0 = the typeahead default). The search page and + /// its export ask for more so a narrowed query can list and count every match; capped by SearchConfig.MaxPageWindow. + /// + public int MaxCandidates { get; set; } + + /// + /// Authorize every candidate in the window so an exact total can be returned. A caller that shows no total (the + /// command palette) turns it off and authorization stops once the page is full; Total is then null. + /// + public bool CountTotal { get; set; } = true; + } + + /// Result orderings the unified endpoint understands. + public static class SearchSortOrders + { + public const string Relevance = "relevance"; + public const string Newest = "newest"; + public const string Oldest = "oldest"; + + public static string Normalize(string sort) + { + if (string.Equals(sort, Newest, StringComparison.OrdinalIgnoreCase)) return Newest; + if (string.Equals(sort, Oldest, StringComparison.OrdinalIgnoreCase)) return Oldest; + return Relevance; + } } public class UnifiedSearchHit @@ -66,6 +102,8 @@ public class UnifiedSearchHit public DateTime? OccurredOn { get; set; } public string Category { get; set; } public string Status { get; set; } + /// The part of the indexed text around the first match (full-text queries only); plain text, never HTML. + public string Snippet { get; set; } public IDictionary Metadata { get; set; } = new Dictionary(); } @@ -88,6 +126,9 @@ public class UnifiedSearchResult public string DegradedReason { get; set; } + /// True while the department's index is queued for, or in the middle of, a build: results may be incomplete. + public bool IndexBuilding { get; set; } + public int QueryTimeMs { get; set; } } diff --git a/Core/Resgrid.Model/Security/AdpStepUp.cs b/Core/Resgrid.Model/Security/AdpStepUp.cs new file mode 100644 index 000000000..71a16f33b --- /dev/null +++ b/Core/Resgrid.Model/Security/AdpStepUp.cs @@ -0,0 +1,155 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// Who is asking for a Protected Data Grant (passkey plan sections 8.1 and 9): the authenticated caller in one department, + /// with the session its validation just loaded and checked. Nothing here comes from the client. + /// + public sealed class AdpStepUpCaller + { + public string UserId { get; init; } + public string UserName { get; init; } + public int DepartmentId { get; init; } + + /// + /// The validated session. Null for an untracked legacy credential, which can obtain only a version 1 grant after a + /// fresh authenticator code; every other method is bound to a tracked session. + /// + public ProtectedGrantSessionContext Session { get; init; } + + /// The sid claim of an untracked credential, carried into a version 1 grant only. + public string LegacySessionId { get; init; } + + /// The calling client when there is no validated session (version 1 only). + public UserSessionClientApplication ClientApplication { get; init; } + + /// The account's current authentication generation, for the evidence of an untracked credential. + public long AccountAuthenticationGeneration { get; init; } + + /// + /// Where the verification is recorded as evidence. Defaults to the tracked session; Web passes its own key, which also + /// covers an untracked Web session. + /// + public string EvidenceSessionKey { get; init; } + + /// The evidence key actually used: the explicit one, or the tracked session's. + public string EvidenceKey => EvidenceSessionKey ?? MfaEvidence.TrackedSessionKey(Session?.SessionId); + + public string IpAddress { get; init; } + public SystemAuditSystems AuditSystem { get; init; } + + /// The client the grant is for: the validated session's, or the caller's own for a version 1 grant. + public UserSessionClientApplication Client => + Session != null ? (UserSessionClientApplication)Session.ClientApplication : ClientApplication; + + /// The passkey ceremony caller for this request: the same session, client and department. + public PasskeyCaller ToPasskeyCaller() => new() + { + UserId = UserId, + UserName = UserName, + SessionId = Session?.SessionId, + ClientApplication = Client, + AuthenticationGeneration = Session?.AuthenticationGeneration ?? 0, + SessionLockVersion = Session?.SessionLockVersion, + DepartmentId = DepartmentId, + SharedMode = Session?.SharedMode == true, + AuditSystem = AuditSystem, + IpAddress = IpAddress + }; + } + + public enum AdpGrantOutcome + { + Issued = 0, + + /// No signing material on this host: the operation is unavailable, never a token-less success (plan section 8.1). + NotConfigured, + + /// No tracked session: a passkey, approval or provider step-up grant is always bound to one. + SessionRequired, + + /// The department, the deployment or this client does not accept the method for protected data now. + MethodNotAllowed, + + /// The verification is too old for the department's window, or the session ends first: verify again. + StepUpRequired, + + /// The factor did not verify. + VerificationFailed, + + /// The approval has not been decided yet. + ApprovalPending, + + /// The approval was denied, expired, already used, or was for something else. + ApprovalUnavailable, + + /// The credential behind the verification was revoked or changed while it was being used. + CredentialRevoked, + + InvalidRequest, + + ServiceUnavailable + } + + /// A grant issued from a verification, or why none was (value-free). + public sealed class AdpGrantIssue + { + public AdpGrantOutcome Outcome { get; init; } + public string GrantId { get; init; } + public string Token { get; init; } + + /// The grant's absolute expiry (plan section 9.2). Clients conceal at this time; they never add the window themselves. + public DateTime ExpiresOnUtc { get; init; } + + /// The department's configured window, for display only. + public int WindowMinutes { get; init; } + + /// A more specific value-free code than the outcome's, when there is one (a passkey or approval outcome). + public string Error { get; init; } + + public bool Succeeded => Outcome == AdpGrantOutcome.Issued; + + public string ErrorCode => Succeeded ? null : Error ?? AdpGrantOutcomes.ErrorCode(Outcome); + + public static AdpGrantIssue Of(AdpGrantOutcome outcome, string error = null) => new() { Outcome = outcome, Error = error }; + } + + public static class AdpGrantOutcomes + { + public static string ErrorCode(AdpGrantOutcome outcome) => outcome switch + { + AdpGrantOutcome.Issued => null, + AdpGrantOutcome.NotConfigured => "grants_not_configured", + AdpGrantOutcome.SessionRequired => "session_required", + AdpGrantOutcome.MethodNotAllowed => "mfa_method_not_allowed", + AdpGrantOutcome.StepUpRequired => "step_up_required", + AdpGrantOutcome.VerificationFailed => "mfa_verification_failed", + AdpGrantOutcome.ApprovalPending => "approval_pending", + AdpGrantOutcome.ApprovalUnavailable => "approval_expired", + AdpGrantOutcome.CredentialRevoked => "grant_revoked", + AdpGrantOutcome.InvalidRequest => "invalid_request", + _ => "service_unavailable" + }; + + /// The HTTP status an API or Web JSON endpoint answers with. + public static int StatusFor(AdpGrantOutcome outcome) => outcome switch + { + AdpGrantOutcome.Issued => 200, + AdpGrantOutcome.NotConfigured or AdpGrantOutcome.ServiceUnavailable => 503, + AdpGrantOutcome.SessionRequired or AdpGrantOutcome.ApprovalPending => 409, + AdpGrantOutcome.StepUpRequired or AdpGrantOutcome.VerificationFailed or AdpGrantOutcome.CredentialRevoked => 401, + _ => 400 + }; + } + + /// + /// The credential or evidence behind a verification, as a version 2 grant names it (plan section 8.2): an opaque + /// reference and the state version it had. Revoking or changing the credential changes one or the other. + /// + public sealed class MfaCredentialSnapshot + { + public string CredentialId { get; init; } + public long StateVersion { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/AuthenticationChallenge.cs b/Core/Resgrid.Model/Security/AuthenticationChallenge.cs new file mode 100644 index 000000000..bd73f67aa --- /dev/null +++ b/Core/Resgrid.Model/Security/AuthenticationChallenge.cs @@ -0,0 +1,118 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// A single-use WebAuthn ceremony challenge (passkey plan section 5.2). The database row is the authority: a challenge + /// is spent by one compare-and-set, never by a cache GET then DELETE, and nothing revives a spent challenge. It belongs + /// to exactly one user, purpose, client, parent (session or transaction) and authentication generation. + /// + public class AuthenticationChallenge + { + /// Opaque request id handed to the client; knowing it alone cannot complete anything. + public string AuthenticationChallengeId { get; set; } + + public string UserId { get; set; } + + public int Purpose { get; set; } + + public int ClientApplication { get; set; } + + public string RpId { get; set; } + + public int ParentKind { get; set; } + + public string ParentId { get; set; } + + public int? DepartmentId { get; set; } + + public long AuthenticationGeneration { get; set; } + + /// Shared-session lock version the challenge was issued at, when the parent is a shared session. + public long? LockVersion { get; set; } + + /// The exact server options (including the random challenge) the ceremony must be verified against. + public string OptionsJson { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + public DateTime ExpiresOnUtc { get; set; } + + public int Attempts { get; set; } + + public int MaxAttempts { get; set; } + + public int State { get; set; } + + public DateTime? ConsumedOnUtc { get; set; } + + public AuthenticationChallengePurpose ChallengePurpose => (AuthenticationChallengePurpose)Purpose; + + public AuthenticationChallengeState ChallengeState => (AuthenticationChallengeState)State; + } + + public enum AuthenticationChallengePurpose + { + PasskeyRegistration = 1, + LoginSecondFactor = 2, + AdpStepUp = 3, + SensitiveOperation = 4, + AccountReauthentication = 5, + SharedDeviceUnlock = 6, + ApprovalResponse = 7 + } + + public enum AuthenticationChallengeParentKind + { + Session = 1, + LoginTransaction = 2, + RecoveryTransaction = 3, + ApprovalRequest = 4 + } + + public enum AuthenticationChallengeState + { + Pending = 0, + Consumed = 1, + /// Too many failed verifications; the ceremony must restart. + Exhausted = 2, + Canceled = 3 + } + + /// What the caller must match for a challenge to be usable: everything it was issued to. + public sealed class AuthenticationChallengeBinding + { + public string UserId { get; set; } + public AuthenticationChallengePurpose Purpose { get; set; } + public UserSessionClientApplication ClientApplication { get; set; } + public AuthenticationChallengeParentKind ParentKind { get; set; } + public string ParentId { get; set; } + public int? DepartmentId { get; set; } + public long AuthenticationGeneration { get; set; } + public long? LockVersion { get; set; } + } + + public enum AuthenticationChallengeOutcome + { + Usable = 0, + NotFound = 1, + Expired = 2, + AlreadyUsed = 3, + BindingMismatch = 4, + /// The account's authentication generation moved (password change, revocation) since it was issued. + Stale = 5, + TooManyAttempts = 6, + Unavailable = 7 + } + + public sealed class AuthenticationChallengeResult + { + public AuthenticationChallengeOutcome Outcome { get; init; } + public AuthenticationChallenge Challenge { get; init; } + + public bool IsUsable => Outcome == AuthenticationChallengeOutcome.Usable; + + public static AuthenticationChallengeResult Of(AuthenticationChallengeOutcome outcome, AuthenticationChallenge challenge = null) + => new() { Outcome = outcome, Challenge = challenge }; + } +} diff --git a/Core/Resgrid.Model/Security/BrokerSessionAssertion.cs b/Core/Resgrid.Model/Security/BrokerSessionAssertion.cs new file mode 100644 index 000000000..1bd7d5adb --- /dev/null +++ b/Core/Resgrid.Model/Security/BrokerSessionAssertion.cs @@ -0,0 +1,110 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Model.Providers; + +namespace Resgrid.Model.Security +{ + /// + /// The identity tier's short-lived statement to the Protected Data Broker about the end user behind one attended + /// request (passkey workbook section 6.2). The calling Web or API host mints it after its session validation passes; + /// the broker checks it against the grant and the live session. It is internal, lasts about a minute, is bound to one + /// request by , and is never a substitute for a user or API credential. + /// + public sealed class BrokerSessionAssertion + { + public const string HeaderName = "X-Resgrid-Session-Assertion"; + + public string UserId { get; init; } + public string SessionId { get; init; } + public long AuthenticationGeneration { get; init; } + public int DepartmentId { get; init; } + public int ClientApplication { get; init; } + public long? SessionLockVersion { get; init; } + + /// The caller credential's issue time that session validation checked (cred_iat), when known. + public DateTime? CredentialIssuedOnUtc { get; init; } + + /// SHA-256 of the request id, operation and items (). + public string RequestDigest { get; init; } + + /// jti: single use at the broker. + public string AssertionId { get; init; } + + public DateTime IssuedAtUtc { get; init; } + public DateTime ExpiresOnUtc { get; init; } + } + + public enum BrokerSessionAssertionOutcome + { + Valid = 0, + + /// No assertion certificate is configured on this host. + NotConfigured = 1, + + /// Unparseable, wrong algorithm, issuer or audience, bad signature, or a missing claim. + Invalid = 2, + + /// Outside its lifetime (bounded skew). + Expired = 3, + + /// Minted for a different request, operation or item list. + RequestMismatch = 4 + } + + /// + /// The canonical digest that binds a session assertion to exactly one broker request. Both sides compute it from the + /// same values, so any change to the department, request id, operation or any item invalidates the assertion. + /// + public static class BrokerRequestDigest + { + public static string Compute(string operation, int departmentId, string requestId, IReadOnlyList items) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + Append(hash, "resgrid-broker-request/1"); + Append(hash, operation); + Append(hash, departmentId.ToString(CultureInfo.InvariantCulture)); + Append(hash, requestId); + + var count = items?.Count ?? 0; + Append(hash, count.ToString(CultureInfo.InvariantCulture)); + for (var i = 0; i < count; i++) + { + var item = items[i]; + if (item == null) + { + Append(hash, null); + continue; + } + + Append(hash, item.FieldId); + Append(hash, item.RowKey); + Append(hash, item.IsBinary ? "1" : "0"); + Append(hash, item.CatalogVersion.ToString(CultureInfo.InvariantCulture)); + Append(hash, item.Value); + } + + return Convert.ToHexString(hash.GetHashAndReset()); + } + + // Length-prefixed, so no two different inputs can produce the same byte stream; null is distinct from "". + private static void Append(IncrementalHash hash, string value) + { + Span length = stackalloc byte[4]; + if (value == null) + { + BinaryPrimitives.WriteInt32BigEndian(length, -1); + hash.AppendData(length); + return; + } + + var bytes = Encoding.UTF8.GetBytes(value); + BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); + hash.AppendData(length); + hash.AppendData(bytes); + } + } +} diff --git a/Core/Resgrid.Model/Security/FactorRecoveryTransaction.cs b/Core/Resgrid.Model/Security/FactorRecoveryTransaction.cs new file mode 100644 index 000000000..e02ef4d79 --- /dev/null +++ b/Core/Resgrid.Model/Security/FactorRecoveryTransaction.cs @@ -0,0 +1,111 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// A restricted factor recovery (passkey plan sections 5.4 and 6.3): a verified first factor and a spent recovery code + /// open it; it permits only status, replacement-factor setup, completion and cancellation, and grants no ordinary or ADP + /// access. Completion replaces the authenticator, rotates the recovery codes, removes the lost passkeys the user chose, + /// and ends every session. The row is the authority and every change is one compare-and-set; only a SHA-256 hash of its + /// secret is stored. + /// + public class FactorRecoveryTransaction + { + public string FactorRecoveryTransactionId { get; set; } + + public byte[] SecretHash { get; set; } + + public string UserId { get; set; } + + /// The client that started it; only that client may continue it. + public int ClientApplication { get; set; } + + /// The first factor that opened it: or . + public int FirstFactorMethod { get; set; } + + public DateTime FirstFactorVerifiedOnUtc { get; set; } + + /// For an SSO first factor, the configuration that verified it. + public string DepartmentSsoConfigId { get; set; } + + public int? DepartmentId { get; set; } + + /// The account's generation when it opened; any change voids it. + public long AuthenticationGeneration { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + /// Ten minutes after creation, never extended. + public DateTime ExpiresOnUtc { get; set; } + + public int Attempts { get; set; } + + public int MaxAttempts { get; set; } + + public int State { get; set; } + + public DateTime? CompletedOnUtc { get; set; } + + public FactorRecoveryState RecoveryState => (FactorRecoveryState)State; + } + + public enum FactorRecoveryState + { + Pending = 0, + + /// The replacement committed. Terminal: the user signs in normally. + Completed = 1, + + /// The user canceled. Terminal; the recovery code that opened it stays spent. + Canceled = 2, + + /// Too many wrong codes for the new authenticator. Terminal. + Exhausted = 3 + } + + public enum FactorRecoveryOutcome + { + Usable = 0, + Invalid, + Expired, + AlreadyUsed, + TooManyAttempts, + + /// The account's sign-in state changed since the recovery began. + SessionRevoked, + + Unavailable + } + + public static class FactorRecoveryOutcomes + { + public static string ErrorCode(FactorRecoveryOutcome outcome) => outcome switch + { + FactorRecoveryOutcome.Invalid => "recovery_transaction_invalid", + FactorRecoveryOutcome.Expired => "recovery_transaction_expired", + FactorRecoveryOutcome.AlreadyUsed => "recovery_transaction_invalid", + FactorRecoveryOutcome.TooManyAttempts => "too_many_attempts", + FactorRecoveryOutcome.SessionRevoked => "session_revoked", + FactorRecoveryOutcome.Unavailable => "service_unavailable", + _ => null + }; + } + + public sealed class FactorRecoveryStart + { + public string Secret { get; init; } + public int ExpiresInSeconds { get; init; } + public FactorRecoveryTransaction Transaction { get; init; } + } + + public sealed class FactorRecoveryResult + { + public FactorRecoveryOutcome Outcome { get; init; } + public FactorRecoveryTransaction Transaction { get; init; } + + public bool IsUsable => Outcome == FactorRecoveryOutcome.Usable; + + public static FactorRecoveryResult Of(FactorRecoveryOutcome outcome, FactorRecoveryTransaction transaction = null) => + new() { Outcome = outcome, Transaction = transaction }; + } +} diff --git a/Core/Resgrid.Model/Security/FederatedMfaMapping.cs b/Core/Resgrid.Model/Security/FederatedMfaMapping.cs new file mode 100644 index 000000000..8f9881a52 --- /dev/null +++ b/Core/Resgrid.Model/Security/FederatedMfaMapping.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Resgrid.Model.Security +{ + /// + /// A department's provider step-up mapping (passkey plan section 7.8): what Resgrid asks the identity provider for, and + /// which values the provider returns that count as MFA. It is a validated structure, never free text, and it takes + /// effect only after a successful test step-up with the same version (). + /// + public sealed class FederatedMfaMapping + { + private const int MaxValues = 10; + private const int MaxValueLength = 256; + private const int MaxClaimsLength = 2048; + + private static readonly JsonSerializerOptions Json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + // ── What to request ───────────────────────────────────────────────────────── + + /// OIDC acr_values to request, such as an Okta assurance level. + public List RequestAcrValues { get; set; } + + /// An OIDC claims request (a JSON object), such as an Entra authentication-context acrs value. + public string RequestClaims { get; set; } + + /// SAML RequestedAuthnContext class references, such as the REFEDS MFA profile. + public List RequestAuthnContextClassRefs { get; set; } + + // ── What counts as MFA (the response must carry at least one) ────────────────── + + public List AcceptAmr { get; set; } + public List AcceptAcr { get; set; } + public List AcceptAcrs { get; set; } + public List AcceptAuthnContextClassRefs { get; set; } + + public string Serialize() => JsonSerializer.Serialize(this, Json); + + /// The mapping in a stored or submitted JSON document, or null when it is empty or unreadable. + public static FederatedMfaMapping Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Json); + } + catch (JsonException) + { + return null; + } + } + + /// Why the mapping cannot be used with the provider type; null when it is valid. + public static string Validate(FederatedMfaMapping mapping, SsoProviderType providerType) + { + if (mapping == null) + return "The mapping is empty or is not valid JSON."; + + foreach (var (name, values) in new[] + { + ("requestAcrValues", mapping.RequestAcrValues), ("requestAuthnContextClassRefs", mapping.RequestAuthnContextClassRefs), + ("acceptAmr", mapping.AcceptAmr), ("acceptAcr", mapping.AcceptAcr), ("acceptAcrs", mapping.AcceptAcrs), + ("acceptAuthnContextClassRefs", mapping.AcceptAuthnContextClassRefs) + }) + { + var problem = ValidateValues(name, values); + if (problem != null) + return problem; + } + + // A password is never a second factor, whatever the provider calls it. + if (mapping.AcceptAmr?.Any(value => string.Equals(value, "pwd", StringComparison.OrdinalIgnoreCase)) == true) + return "acceptAmr cannot accept \"pwd\": a password is not MFA."; + + if (providerType == SsoProviderType.Oidc) + { + if (mapping.RequestAuthnContextClassRefs?.Count > 0 || mapping.AcceptAuthnContextClassRefs?.Count > 0) + return "SAML AuthnContext values do not apply to an OIDC provider."; + if (Count(mapping.AcceptAmr) + Count(mapping.AcceptAcr) + Count(mapping.AcceptAcrs) == 0) + return "Name at least one amr, acr or acrs value that counts as MFA."; + + if (!string.IsNullOrWhiteSpace(mapping.RequestClaims)) + { + if (mapping.RequestClaims.Length > MaxClaimsLength) + return "requestClaims is too long."; + try + { + using var claims = JsonDocument.Parse(mapping.RequestClaims); + if (claims.RootElement.ValueKind != JsonValueKind.Object || claims.RootElement.EnumerateObject() + .Any(member => member.Name != "id_token" && member.Name != "userinfo")) + return "requestClaims must be a JSON object with id_token and/or userinfo members."; + } + catch (JsonException) + { + return "requestClaims is not valid JSON."; + } + } + } + else if (providerType == SsoProviderType.Saml2) + { + if (mapping.RequestAcrValues?.Count > 0 || !string.IsNullOrWhiteSpace(mapping.RequestClaims) || + Count(mapping.AcceptAmr) + Count(mapping.AcceptAcr) + Count(mapping.AcceptAcrs) > 0) + return "OIDC values do not apply to a SAML provider."; + if (Count(mapping.AcceptAuthnContextClassRefs) == 0) + return "Name at least one AuthnContextClassRef that counts as MFA."; + } + else + { + return "Unknown provider type."; + } + + return null; + } + + /// + /// The first returned value this mapping counts as MFA, as kind:value for evidence and audit; null when the + /// response carries none. Matching is exact and case-sensitive, as providers define these identifiers. + /// + public string Match(FederatedMfaSignals signals) + { + if (signals == null) + return null; + + return First("amr", AcceptAmr, signals.Amr) + ?? First("acr", AcceptAcr, signals.Acr) + ?? First("acrs", AcceptAcrs, signals.Acrs) + ?? First("authncontext", AcceptAuthnContextClassRefs, signals.AuthnContextClassRefs); + } + + /// Whether the configuration's mapping has passed a test at its current version (plan section 7.8). + public static bool IsTested(DepartmentSsoConfig config) => + config != null && config.IsEnabled && !string.IsNullOrWhiteSpace(config.FederatedMfaMappingJson) && + config.FederatedMfaMappingVersion > 0 && config.FederatedMfaTestedVersion == config.FederatedMfaMappingVersion; + + /// + /// Whether a redeemed brokered round trip carried provider MFA under the department's tested mapping as it is now: + /// the same configuration and mapping version, with a matched value (plan section 7.8 acceptance rules). + /// + public static bool Satisfies(SsoLoginTransaction transaction, DepartmentSsoConfig testedConfig) => + transaction != null && IsTested(testedConfig) && !string.IsNullOrWhiteSpace(transaction.FederatedMfaValue) && + transaction.DepartmentId == testedConfig.DepartmentId && + string.Equals(transaction.DepartmentSsoConfigId, testedConfig.DepartmentSsoConfigId, StringComparison.Ordinal) && + transaction.FederatedMappingVersion == testedConfig.FederatedMfaMappingVersion; + + /// The MFA evidence factor reference for provider step-up: the SSO configuration and its mapping version. + public static string FactorReferenceFor(string departmentSsoConfigId, long mappingVersion) => + $"federated:{departmentSsoConfigId}:{mappingVersion}"; + + private static string First(string kind, IEnumerable accepted, IEnumerable returned) + { + if (accepted == null || returned == null) + return null; + + var values = returned.Where(value => !string.IsNullOrEmpty(value)).ToHashSet(StringComparer.Ordinal); + var match = accepted.FirstOrDefault(values.Contains); + return match == null ? null : $"{kind}:{match}"; + } + + private static string ValidateValues(string name, IReadOnlyCollection values) + { + if (values == null) + return null; + if (values.Count > MaxValues) + return $"{name} lists more than {MaxValues} values."; + if (values.Any(value => string.IsNullOrWhiteSpace(value) || value.Length > MaxValueLength || value.Any(char.IsWhiteSpace) || value.Any(char.IsControl))) + return $"{name} has an empty value, a value over {MaxValueLength} characters, or a value containing spaces."; + if (values.Distinct(StringComparer.Ordinal).Count() != values.Count) + return $"{name} lists a value twice."; + return null; + } + + private static int Count(IReadOnlyCollection values) => values?.Count ?? 0; + } + + /// The MFA signals an identity provider returned: OIDC amr, acr, acrs, or SAML AuthnContext. + public sealed class FederatedMfaSignals + { + public IReadOnlyCollection Amr { get; init; } + public IReadOnlyCollection Acr { get; init; } + public IReadOnlyCollection Acrs { get; init; } + public IReadOnlyCollection AuthnContextClassRefs { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/LegacyAppCallbacks.cs b/Core/Resgrid.Model/Security/LegacyAppCallbacks.cs new file mode 100644 index 000000000..97d9da238 --- /dev/null +++ b/Core/Resgrid.Model/Security/LegacyAppCallbacks.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Resgrid.Model.Security +{ + /// + /// Each app's own auth/callback for the legacy (unbrokered) SSO flows: the OIDC redirect URI the app sends its IdP, + /// and the target the legacy SAML relay returns to. Every app has its own custom scheme, so a department's IdP must list + /// each app's URI: an app can only receive callbacks on a scheme it owns, and one app's callback never reaches another. + /// The schemes are the ones the app builds register (lowercase: Android matches intent-filter schemes case-sensitively, + /// and IdPs compare redirect URIs as exact strings). A caller that names no app is treated as Responder, which is what + /// the single legacy URI always meant. An app's web build returns to its own page instead ( + /// under the origin SsoConfig.AppWebOrigins names for it). + /// + public static class LegacyAppCallbacks + { + /// + /// One app's legacy callback, with the name it goes by in X-Resgrid-Client and a tagged RelayState, and the path + /// its web build returns to (Expo builds the web redirect as the page's origin plus this path). + /// + public sealed record AppCallback(UserSessionClientApplication Client, string Name, string DisplayName, string Callback, string WebPath); + + /// A redirect URI to register with the IdP: an app's native callback, or its web build's page. + public sealed record AppRedirectUri(string Name, string DisplayName, bool Web, string Uri); + + /// Every app with a legacy callback, in the order an admin page lists them. + public static readonly IReadOnlyList All = new[] + { + new AppCallback(UserSessionClientApplication.Responder, "responder", "Resgrid Responder", "resgrid://auth/callback", "/auth/callback"), + new AppCallback(UserSessionClientApplication.Unit, "unit", "Resgrid Unit", "resgridunit://auth/callback", "/auth/callback"), + new AppCallback(UserSessionClientApplication.Dispatch, "dispatch", "Resgrid Dispatch", "resgriddispatch://auth/callback", "/login/sso"), + new AppCallback(UserSessionClientApplication.Command, "ic", "Resgrid IC", "resgridic://auth/callback", "/auth/callback") + }; + + /// The app a caller that names none is served as. + public static AppCallback Default => All[0]; + + /// The callback for this app; any other caller (no header, Web, an API client) gets Responder's. + public static AppCallback For(UserSessionClientApplication client) => All.FirstOrDefault(a => a.Client == client) ?? Default; + + /// The app with this name (as a tagged RelayState spells it), or null. + public static AppCallback ForName(string name) => All.FirstOrDefault(a => a.Name == name); + + /// + /// Every redirect URI a department registers for the apps' own sign-in, per app: its native callback, then its web + /// build's page when (SsoConfig.AppWebOrigins) names an origin for it. An entry + /// that is not a clean origin is left out rather than shown. + /// + public static IReadOnlyList RedirectUris(string webOrigins) + { + var origins = ParseWebOrigins(webOrigins); + var uris = new List(); + foreach (var app in All) + { + uris.Add(new AppRedirectUri(app.Name, app.DisplayName, false, app.Callback)); + if (origins.TryGetValue(app.Name, out var origin)) + uris.Add(new AppRedirectUri(app.Name, app.DisplayName, true, origin + app.WebPath)); + } + + return uris; + } + + private static Dictionary ParseWebOrigins(string configuration) + { + var origins = new Dictionary(StringComparer.Ordinal); + foreach (var entry in (configuration ?? string.Empty).Split(';', StringSplitOptions.TrimEntries)) + { + // An entry with no "=" (a blank one included) names nothing; an empty or unknown name is no app. + var equals = entry.IndexOf('='); + if (equals < 0) + continue; + + var app = ForName(entry[..equals].Trim().ToLowerInvariant()); + var origin = CleanOrigin(entry[(equals + 1)..].Trim()); + if (app != null && origin != null && !origins.ContainsKey(app.Name)) + origins[app.Name] = origin; + } + + return origins; + } + + // scheme://host[:port], an optional trailing slash, and nothing else: no path, query, fragment or user info. + private static readonly Regex OriginPattern = new(@"^https?://([a-z0-9-]+(\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])(:[0-9]{1,5})?/?\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// + /// The origin as a browser writes it (lowercase, no default port), or null. It must be https, or http for localhost and + /// .local development hosts. The page shows these to be typed into an IdP, so anything else is not shown at all. + /// + private static string CleanOrigin(string value) + { + if (!OriginPattern.IsMatch(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri)) + return null; + + var development = uri.IsLoopback || uri.Host.EndsWith(".local", StringComparison.OrdinalIgnoreCase); + if (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && development)) + return null; + + // Uri writes the scheme and host in lowercase and leaves out a default port. + return uri.GetLeftPart(UriPartial.Authority); + } + } +} diff --git a/Core/Resgrid.Model/Security/MfaActivity.cs b/Core/Resgrid.Model/Security/MfaActivity.cs new file mode 100644 index 000000000..a1147075a --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaActivity.cs @@ -0,0 +1,71 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// One verification on the account, successful or denied, for the account's "Recent MFA activity" (plan section 6.5): + /// when, which method, which app and installation asked, why, and whether it was a shared installation. Kept for + /// TwoFactorConfig.MfaActivityRetentionDays. Never holds a code, assertion or anything that verifies again. + /// + public class MfaActivity + { + public string MfaActivityId { get; set; } + public string UserId { get; set; } + public DateTime OccurredOnUtc { get; set; } + + /// . + public int Method { get; set; } + + /// . + public int Purpose { get; set; } + + public bool Successful { get; set; } + public int ClientApplication { get; set; } + public string InstallationLabel { get; set; } + public bool SharedMode { get; set; } + public int? DepartmentId { get; set; } + + /// The session the verification was for, when there was one; a sign-in in progress has none yet. + public string SessionId { get; set; } + + /// For a Responder approval: the Responder session that approved or denied it. + public string ApproverSessionId { get; set; } + + /// When the user reported it as not theirs. + public DateTime? ReportedOnUtc { get; set; } + } + + /// What a caller knows about a verification. The service fills the installation and shared flag from the session. + public sealed class MfaActivityEntry + { + public string UserId { get; init; } + public MfaEvidenceMethod Method { get; init; } + public MfaEvidencePurpose Purpose { get; init; } + public bool Successful { get; init; } + public UserSessionClientApplication ClientApplication { get; init; } + public string InstallationLabel { get; init; } + public bool SharedMode { get; init; } + public int? DepartmentId { get; init; } + public string SessionId { get; init; } + public string ApproverSessionId { get; init; } + } + + public enum MfaActivityReportOutcome + { + Reported = 0, + + /// No such activity on this account, or it is past retention. + NotFound, + + /// It was already reported. + AlreadyReported + } + + public sealed class MfaActivityReport + { + public MfaActivityReportOutcome Outcome { get; init; } + + /// The session the verification opened or served was ended. + public bool SessionEnded { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/MfaApprovalRequest.cs b/Core/Resgrid.Model/Security/MfaApprovalRequest.cs new file mode 100644 index 000000000..a2171804b --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaApprovalRequest.cs @@ -0,0 +1,315 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Resgrid.Model.Security +{ + /// + /// A Responder approval request (passkey plan sections 5.6 and 7.9): the user approves another app's sign-in or step-up + /// from their own Responder with Responder's passkey, after typing the number shown on the requesting screen. The row + /// is the authority and every change is one compare-and-set; the number is stored only as a hash and never pushed. + /// + public class MfaApprovalRequest + { + /// The public approval_request_id. Knowing it approves nothing. + public string MfaApprovalRequestId { get; set; } + + public string UserId { get; set; } + + // ── The requesting context ──────────────────────────────────────────────── + + /// : a login transaction or a signed-in session. + public int RequesterKind { get; set; } + + /// The login transaction's row id or the session id; only that requester can read or consume the result. + public string RequesterId { get; set; } + + /// The requesting ; never Responder. + public int ClientApplication { get; set; } + + /// The requesting installation's label from the server's session record, shown to the approver. + public string InstallationLabel { get; set; } + + public bool SharedMode { get; set; } + + public int? DepartmentId { get; set; } + + /// . + public int Purpose { get; set; } + + /// For a step-up: the operation (). + public string Operation { get; set; } + + /// For a shared session: the lock version it must still have when completed (slice 13). + public long? LockVersion { get; set; } + + /// The account's authentication generation when requested; any change voids the request. + public long AuthenticationGeneration { get; set; } + + /// SHA-256 of the request id and the two-digit number; the number itself is never stored or pushed. + public byte[] MatchNumberHash { get; set; } + + /// Coarse, server-observed origin (region and country only), for display. + public string OriginRegion { get; set; } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + public int State { get; set; } + + /// Advanced by every change, so each transition is one compare-and-set. + public long Version { get; set; } + + /// Wrong numbers entered; reaching denies the request. + public int Attempts { get; set; } + + public int MaxAttempts { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + /// Two minutes after creation, never extended. + public DateTime ExpiresOnUtc { get; set; } + + public DateTime? DecidedOnUtc { get; set; } + + public DateTime? ConsumedOnUtc { get; set; } + + /// for a denied or canceled request. + public int? EndReason { get; set; } + + // ── The approver ────────────────────────────────────────────────────────── + + public string ApproverSessionId { get; set; } + + public string ApproverPasskeyId { get; set; } + + public MfaApprovalRequestState RequestState => (MfaApprovalRequestState)State; + public MfaApprovalPurpose RequestPurpose => (MfaApprovalPurpose)Purpose; + public MfaApprovalRequesterKind Requester => (MfaApprovalRequesterKind)RequesterKind; + + /// The state as it reads now: a pending request past its expiry is expired, whether or not a row says so yet. + public MfaApprovalRequestState EffectiveState(DateTime utcNow) => + RequestState == MfaApprovalRequestState.Pending && ExpiresOnUtc <= utcNow ? MfaApprovalRequestState.Expired : RequestState; + + /// + /// The evidence factor reference for an approval: the approving Responder passkey and session. Evidence and sign-ins + /// with it stop counting when either is revoked or approval is turned off (plan section 7.9 revocation). + /// + public static string FactorReferenceFor(string approverPasskeyId, string approverSessionId) => + $"{FactorReferencePrefix}{approverPasskeyId}:{approverSessionId}"; + + public const string FactorReferencePrefix = "approval:"; + + /// The prefix every approval by one passkey carries, whichever Responder session approved. + public static string FactorReferencePrefixFor(string approverPasskeyId) => $"{FactorReferencePrefix}{approverPasskeyId}:"; + + public static bool TryParseFactorReference(string factorReference, out string approverPasskeyId, out string approverSessionId) + { + approverPasskeyId = approverSessionId = null; + if (string.IsNullOrWhiteSpace(factorReference) || !factorReference.StartsWith(FactorReferencePrefix, StringComparison.Ordinal)) + return false; + + var parts = factorReference[FactorReferencePrefix.Length..].Split(':'); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) + return false; + + approverPasskeyId = parts[0]; + approverSessionId = parts[1]; + return true; + } + + /// The stored form of a match number: bound to its request, compared in constant time. + public static byte[] HashMatchNumber(string approvalRequestId, string matchNumber) => + SHA256.HashData(Encoding.UTF8.GetBytes($"mfa-approval:{approvalRequestId}:{matchNumber?.Trim()}")); + } + + public enum MfaApprovalRequesterKind + { + LoginTransaction = 1, + Session = 2 + } + + public enum MfaApprovalPurpose + { + Login = 1, + StepUp = 2, + + /// Protected Data Grants; the approval grant issuer arrives in Phase 2. + Adp = 3, + + /// Shared-session unlock (slice 13). + Unlock = 4, + + /// Web department entry (Phase 3). + DepartmentEntry = 5 + } + + public enum MfaApprovalRequestState + { + Pending = 0, + Approved = 1, + Denied = 2, + Expired = 3, + Canceled = 4, + + /// The requester used the approval once. Terminal. + Consumed = 5 + } + + public enum MfaApprovalEndReason + { + /// The approver pressed Deny. + Declined = 1, + + /// "Deny — I didn't request this": also ends the requester's sign-in and suspends approval requests. + NotMe = 2, + + /// Too many wrong numbers. + TooManyAttempts = 3, + + /// The requester canceled it. + CanceledByRequester = 4, + + /// A newer request for the same user replaced it. + Superseded = 5, + + /// The approving passkey was revoked, or approval was turned off. + ApproverRevoked = 6 + } + + public enum MfaApprovalOutcome + { + Succeeded = 0, + + /// Approval is off, not allowed here, or the user has no eligible Responder. + Unavailable, + + /// Two denials or expiries in a row, or a "not me" denial, within 15 minutes. + Suspended, + + /// More than the per-user request limit, or a concurrent request won. + TooManyRequests, + + /// No such request for this caller (or it belongs to another requester or account state). + NotFound, + + Pending, + Denied, + Expired, + + /// The number typed in Responder is not the one on the requesting screen. + NumberMismatch, + + /// The request needs a Responder session (approver) or a requesting session or transaction. + SessionRequired, + + InvalidRequest, + ServiceUnavailable + } + + public static class MfaApprovalOutcomes + { + public static string ErrorCode(MfaApprovalOutcome outcome) => outcome switch + { + MfaApprovalOutcome.Unavailable => "approval_unavailable", + MfaApprovalOutcome.Suspended => "approval_suspended", + MfaApprovalOutcome.TooManyRequests => "too_many_attempts", + MfaApprovalOutcome.NotFound => "approval_expired", + MfaApprovalOutcome.Pending => "approval_pending", + MfaApprovalOutcome.Denied => "approval_denied", + MfaApprovalOutcome.Expired => "approval_expired", + MfaApprovalOutcome.NumberMismatch => "approval_number_mismatch", + MfaApprovalOutcome.SessionRequired => "session_required", + MfaApprovalOutcome.InvalidRequest => "invalid_request", + MfaApprovalOutcome.ServiceUnavailable => "service_unavailable", + _ => null + }; + + /// The wire state names (workbook section 7.4). + public static string StateName(MfaApprovalRequestState state) => state switch + { + MfaApprovalRequestState.Pending => "pending", + MfaApprovalRequestState.Approved => "approved", + MfaApprovalRequestState.Denied => "denied", + MfaApprovalRequestState.Expired => "expired", + MfaApprovalRequestState.Canceled => "canceled", + MfaApprovalRequestState.Consumed => "consumed", + _ => null + }; + + public static string PurposeName(MfaApprovalPurpose purpose) => purpose switch + { + MfaApprovalPurpose.Login => "login", + MfaApprovalPurpose.StepUp => "step_up", + MfaApprovalPurpose.Adp => "adp", + MfaApprovalPurpose.Unlock => "unlock", + MfaApprovalPurpose.DepartmentEntry => "department_entry", + _ => null + }; + } + + /// Who is asking for approval, from the validated login transaction or session; nothing here comes from the client. + public sealed class MfaApprovalRequester + { + public string UserId { get; init; } + public MfaApprovalRequesterKind Kind { get; init; } + public string RequesterId { get; init; } + public UserSessionClientApplication ClientApplication { get; init; } + public long AuthenticationGeneration { get; init; } + public int? DepartmentId { get; init; } + public MfaApprovalPurpose Purpose { get; init; } + public string Operation { get; init; } + public string InstallationLabel { get; init; } + public bool SharedMode { get; init; } + public long? LockVersion { get; init; } + public string IpAddress { get; init; } + public string UserName { get; init; } + public SystemAuditSystems AuditSystem { get; init; } + + /// The department switches that decide whether approval counts here (plan section 7.6). + public MfaMethodScope Scope => Purpose == MfaApprovalPurpose.StepUp ? MfaStepUpOperations.ScopeFor(Operation) : Purpose == MfaApprovalPurpose.Adp + ? MfaMethodScope.Adp + : MfaMethodScope.Login; + + public static MfaApprovalRequester ForLoginTransaction(MfaLoginTransaction transaction, string userName, string ipAddress, + SystemAuditSystems auditSystem = SystemAuditSystems.Api) => + new() + { + UserId = transaction.UserId, + Kind = MfaApprovalRequesterKind.LoginTransaction, + RequesterId = transaction.MfaLoginTransactionId, + ClientApplication = (UserSessionClientApplication)transaction.ClientApplication, + AuthenticationGeneration = transaction.AuthenticationGeneration, + DepartmentId = transaction.DepartmentId, + Purpose = MfaApprovalPurpose.Login, + // What the first factor recorded: the approver sees a shared workstation's sign-in as one, with its label. + SharedMode = transaction.SharedMode, + InstallationLabel = transaction.InstallationLabel, + IpAddress = ipAddress, + UserName = userName, + AuditSystem = auditSystem + }; + } + + /// A new request: its id and the number to show on the requesting screen only. + public sealed class MfaApprovalStart + { + public MfaApprovalOutcome Outcome { get; init; } + public string ApprovalRequestId { get; init; } + public string MatchNumber { get; init; } + public int ExpiresInSeconds { get; init; } + + public bool Succeeded => Outcome == MfaApprovalOutcome.Succeeded; + + public static MfaApprovalStart Of(MfaApprovalOutcome outcome) => new() { Outcome = outcome }; + } + + public sealed class MfaApprovalResult + { + public MfaApprovalOutcome Outcome { get; init; } + public MfaApprovalRequest Request { get; init; } + + public bool Succeeded => Outcome == MfaApprovalOutcome.Succeeded; + + public static MfaApprovalResult Of(MfaApprovalOutcome outcome, MfaApprovalRequest request = null) => new() { Outcome = outcome, Request = request }; + } +} diff --git a/Core/Resgrid.Model/Security/MfaEvidence.cs b/Core/Resgrid.Model/Security/MfaEvidence.cs new file mode 100644 index 000000000..422ec4b1b --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaEvidence.cs @@ -0,0 +1,89 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// Server-held proof that a factor was verified for one session (passkey plan section 5.3). Controllers never + /// deserialize this from a request; only the server writes it when a verification actually succeeds. Readers compare + /// with the user's current value, so any credential or MFA change that advances + /// the generation retires older evidence without touching these rows. + /// + public class MfaEvidence + { + /// + /// The evidence key of a tracked session, shared by Web and API so both hosts read the same session's evidence + /// (a Web session's API bridge carries the Web session's id). + /// + public static string TrackedSessionKey(string userSessionId) => + string.IsNullOrWhiteSpace(userSessionId) ? null : "sid:" + userSessionId; + + /// The tracked session id a names; null for any other key. + public static string TrackedSessionId(string sessionKey) => + sessionKey != null && sessionKey.Length > 4 && sessionKey.StartsWith("sid:", System.StringComparison.Ordinal) ? sessionKey.Substring(4) : null; + + public string MfaEvidenceId { get; set; } + + public string UserId { get; set; } + + /// The tracked session id as sid:{id}, or web:{id} for an untracked Web session. + public string SessionKey { get; set; } + + public int ClientApplication { get; set; } + + public int Kind { get; set; } + + public int Method { get; set; } + + public int Purpose { get; set; } + + /// Set only for department-scoped evidence (ADP step-up). + public int? DepartmentId { get; set; } + + public DateTime VerifiedOnUtc { get; set; } + + public DateTime ExpiresOnUtc { get; set; } + + public long AuthenticationGeneration { get; set; } + + /// Opaque reference to the factor instance (passkey credential, SSO config), never the secret itself. + public string FactorReference { get; set; } + + public DateTime? RevokedOnUtc { get; set; } + + public MfaEvidenceKind EvidenceKind => (MfaEvidenceKind)Kind; + + public MfaEvidenceMethod EvidenceMethod => (MfaEvidenceMethod)Method; + } + + public enum MfaEvidenceKind + { + /// Password or SSO sign-in / reauthentication. + FirstFactor = 1, + + /// A verified second factor. Only this kind can satisfy MFA. + SecondFactor = 2, + + /// A recovery-code use. Recorded for accountability; never satisfies MFA or ADP (plan section 6.1). + Recovery = 3 + } + + public enum MfaEvidenceMethod + { + Password = 1, + Sso = 2, + Totp = 10, + Passkey = 11, + PasskeyApproval = 12, + Federated = 13, + RecoveryCode = 20 + } + + public enum MfaEvidencePurpose + { + Login = 1, + Reauthentication = 2, + StepUp = 3, + AdpStepUp = 4, + SharedUnlock = 5 + } +} diff --git a/Core/Resgrid.Model/Security/MfaLoginTransaction.cs b/Core/Resgrid.Model/Security/MfaLoginTransaction.cs new file mode 100644 index 000000000..7a6e19196 --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaLoginTransaction.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Security +{ + /// + /// A restricted login after a verified first factor (passkey plan section 5.2). It is not a session and authorizes + /// nothing but completing its own second factor. The row is the authority: every state change is one compare-and-set, + /// a completion is redeemed once, and nothing revives a spent transaction. Only SHA-256 hashes of the transaction + /// secret and the completion code are stored; no password, code, assertion or token. + /// + public class MfaLoginTransaction + { + public string MfaLoginTransactionId { get; set; } + + /// SHA-256 of the high-entropy secret the client holds in memory. + public byte[] SecretHash { get; set; } + + public string UserId { get; set; } + + /// The resolved login department, verified as an active membership before the transaction existed. + public int? DepartmentId { get; set; } + + /// The that started it; completion and redemption must match. + public int ClientApplication { get; set; } + + /// The OAuth client_id of the first-factor request, when one was sent. + public string ClientId { get; set; } + + /// The first factor ( or ). + public int FirstFactorMethod { get; set; } + + /// When the first factor was verified; carried into the session's evidence unchanged. + public DateTime FirstFactorVerifiedOnUtc { get; set; } + + public string DepartmentSsoConfigId { get; set; } + + public long AuthenticationGeneration { get; set; } + + /// The department's MfaPolicyVersion when the transaction began; a policy change voids it. + public long MfaPolicyVersion { get; set; } + + /// Space-separated scopes granted by the first-factor request. + public string Scopes { get; set; } + + /// + /// Whether the session this sign-in creates will be shared (the department's requirement, or the installation's + /// request while shared-device mode is on), decided at the first factor by the rule the session applies. What the + /// member's Responder is shown when asked to approve; the session still decides for itself when it is created. + /// + public bool SharedMode { get; set; } + + /// The label the installation sent with the first factor (display text for the approver only; nothing trusts it). + public string InstallationLabel { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + public DateTime ExpiresOnUtc { get; set; } + + public int Attempts { get; set; } + + public int MaxAttempts { get; set; } + + public int State { get; set; } + + // Set by the one successful completion. + public int? CompletionMethod { get; set; } + public string CompletionFactorReference { get; set; } + public DateTime? CompletionVerifiedOnUtc { get; set; } + public bool IsRecovery { get; set; } + public byte[] CompletionCodeHash { get; set; } + public DateTime? CompletionExpiresOnUtc { get; set; } + public DateTime? RedeemedOnUtc { get; set; } + + public MfaLoginTransactionState TransactionState => (MfaLoginTransactionState)State; + } + + public enum MfaLoginTransactionState + { + Pending = 0, + + /// A second factor verified; a completion code was issued and awaits redemption. + Completed = 1, + + /// The completion code was exchanged for tokens. Terminal. + Redeemed = 2, + + /// Too many failed verifications. Terminal: the user signs in again. + Exhausted = 3, + + Canceled = 4 + } + + /// What the client learns when a login needs a second factor (workbook section 7.1). + public sealed class MfaLoginTransactionStart + { + /// The transaction secret, returned once; the server keeps only its hash. + public string Secret { get; init; } + + public int ExpiresInSeconds { get; init; } + + public MfaMethodChoice Choice { get; init; } + } + + /// Everything the first factor established, for a new transaction. + public sealed class MfaLoginTransactionRequest + { + public string UserId { get; init; } + public int? DepartmentId { get; init; } + public UserSessionClientApplication ClientApplication { get; init; } + public string ClientId { get; init; } + public MfaEvidenceMethod FirstFactorMethod { get; init; } + public DateTime FirstFactorVerifiedOnUtc { get; init; } + public string DepartmentSsoConfigId { get; init; } + public long AuthenticationGeneration { get; init; } + public IReadOnlyCollection Scopes { get; init; } + public bool TotpEnrolled { get; init; } + + /// The first-factor request asked for a shared session (a shared installation or a Web shared workstation). + public bool SharedModeRequested { get; init; } + + /// The label the first-factor request sent for its installation or workstation, as a session would record it. + public string InstallationLabel { get; init; } + } + + public enum MfaLoginTransactionOutcome + { + Usable = 0, + + /// No such transaction for this client (or a completion code that does not match). + Invalid, + + Expired, + + /// Already completed, redeemed or canceled. + AlreadyUsed, + + TooManyAttempts, + + /// The department's MFA policy changed since the first factor. + PolicyChanged, + + /// The account's authentication generation moved (password change, revocation) or the user is gone. + SessionRevoked, + + Unavailable + } + + public sealed class MfaLoginTransactionResult + { + public MfaLoginTransactionOutcome Outcome { get; init; } + public MfaLoginTransaction Transaction { get; init; } + + public bool IsUsable => Outcome == MfaLoginTransactionOutcome.Usable; + + public static MfaLoginTransactionResult Of(MfaLoginTransactionOutcome outcome, MfaLoginTransaction transaction = null) => + new() { Outcome = outcome, Transaction = transaction }; + } + + /// The one-use completion credential for the token endpoint (workbook section 7.1). + public sealed class MfaLoginCompletion + { + public MfaLoginTransactionOutcome Outcome { get; init; } + + /// The transaction secret, set only when the transaction began already complete; redeemed with the code. + public string Transaction { get; init; } + + public string CompletionCode { get; init; } + public int ExpiresInSeconds { get; init; } + + public bool Succeeded => Outcome == MfaLoginTransactionOutcome.Usable && CompletionCode != null; + } + + /// Names and codes shared by the token endpoint and the completion endpoints (workbook sections 7.1 and 7.6). + public static class MfaLoginTransactions + { + public const string FlowParameter = "mfa_flow"; + public const string FlowValue = "transaction"; + public const string CompletionGrantType = "urn:resgrid:params:oauth:grant-type:mfa_completion"; + + public static string ErrorCode(MfaLoginTransactionOutcome outcome) => outcome switch + { + MfaLoginTransactionOutcome.Invalid => "mfa_transaction_invalid", + MfaLoginTransactionOutcome.Expired => "mfa_transaction_expired", + MfaLoginTransactionOutcome.AlreadyUsed => "mfa_transaction_invalid", + MfaLoginTransactionOutcome.TooManyAttempts => "too_many_attempts", + MfaLoginTransactionOutcome.PolicyChanged => "policy_changed", + MfaLoginTransactionOutcome.SessionRevoked => "session_revoked", + MfaLoginTransactionOutcome.Unavailable => "service_unavailable", + _ => null + }; + } +} diff --git a/Core/Resgrid.Model/Security/MfaMethodChoice.cs b/Core/Resgrid.Model/Security/MfaMethodChoice.cs new file mode 100644 index 000000000..75082cdb6 --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaMethodChoice.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace Resgrid.Model.Security +{ + /// + /// The second-factor choice for a user after the first factor (passkey plan section 7.5 rule 5): what the user has + /// enrolled, what the department and deployment accept, and the default to show first. Every allowed method is an + /// equal choice; the preference only orders them. + /// + public sealed class MfaMethodChoice + { + public IReadOnlyList EnrolledMethods { get; init; } + + public IReadOnlyList AllowedMethods { get; init; } + + /// The method to show first; null when nothing is both enrolled and allowed. + public string Preferred { get; init; } + + public bool CanVerify => Preferred != null; + } + + /// The API names of MFA methods; the same strings the version 2 grant uses for mfa_method. + public static class MfaMethodNames + { + public const string Totp = "totp"; + public const string Passkey = "passkey"; + public const string PasskeyApproval = "passkey_approval"; + public const string Federated = "federated"; + + public static string From(MfaEvidenceMethod method) => method switch + { + MfaEvidenceMethod.Totp => Totp, + MfaEvidenceMethod.Passkey => Passkey, + MfaEvidenceMethod.PasskeyApproval => PasskeyApproval, + MfaEvidenceMethod.Federated => Federated, + _ => null + }; + + /// The method a name stands for; TOTP for anything unknown, the default every surface assumes. + public static MfaEvidenceMethod Parse(string name) => name switch + { + Passkey => MfaEvidenceMethod.Passkey, + PasskeyApproval => MfaEvidenceMethod.PasskeyApproval, + Federated => MfaEvidenceMethod.Federated, + _ => MfaEvidenceMethod.Totp + }; + } +} + +namespace Resgrid.Model.Security +{ + /// + /// Which rows of the passkey plan section 7.6 matrix a verification is for; each governs which second factors a + /// department accepts. TOTP is accepted in every scope. + /// + public enum MfaMethodScope + { + /// Sign-in, department entry, generic step-up, chat export and shared unlock (rows 1-8, 12, 15). + Login = 1, + + /// Security, SSO and MFA policy changes (row 13): the sign-in switches, never Responder approval. + SecurityChange = 2, + + /// Protected Data Grants, ADP management and protected-workflow approvals (rows 9-11). + Adp = 3, + + /// Account factor management (row 14): deployment gates only, never a department switch, approval or provider step-up. + Account = 4 + } +} diff --git a/Core/Resgrid.Model/Security/MfaStepUpOperations.cs b/Core/Resgrid.Model/Security/MfaStepUpOperations.cs new file mode 100644 index 000000000..81faa00b0 --- /dev/null +++ b/Core/Resgrid.Model/Security/MfaStepUpOperations.cs @@ -0,0 +1,52 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// The named operations an API step-up is for (passkey plan section 11, Mfa/StepUpOptions and + /// Mfa/VerifyStepUp). Each has a maximum evidence age; a step-up never authorizes anything by itself. + /// + public static class MfaStepUpOperations + { + /// Security, SSO, SCIM and MFA policy changes (section 7.6 row 13). + public const string SecurityChange = "security_change"; + + /// ADP enrollment, offboarding and security commands (section 7.6 row 10). + public const string AdpManagement = "adp_management"; + + /// Chat exports (section 7.6 row 8). + public const string ChatExport = "chat_export"; + + /// The user's own sign-in methods: registering and removing passkeys (section 7.6 row 14). + public const string AccountSecurity = "account_security"; + + /// Any other guarded action: the Web's generic step-up and the other 5-minute operations (section 7.6 rows 7 and 15). + public const string SensitiveOperation = "sensitive_operation"; + + public static bool IsKnown(string operation) => + operation == SecurityChange || operation == AdpManagement || operation == ChatExport || operation == AccountSecurity || + operation == SensitiveOperation; + + /// The operation a step-up for is for, where it is one operation. + public static string ForScope(MfaMethodScope scope) => scope switch + { + MfaMethodScope.SecurityChange => SecurityChange, + MfaMethodScope.Adp => AdpManagement, + MfaMethodScope.Account => AccountSecurity, + _ => SensitiveOperation + }; + + /// Which department switches govern the acceptable methods for the operation (plan section 7.6). + public static MfaMethodScope ScopeFor(string operation) => operation switch + { + SecurityChange => MfaMethodScope.SecurityChange, + AdpManagement => MfaMethodScope.Adp, + AccountSecurity => MfaMethodScope.Account, + _ => MfaMethodScope.Login + }; + + /// How long the evidence from one step-up serves the operation. + public static TimeSpan WindowFor(string operation) => + TimeSpan.FromMinutes(Math.Max(1, Config.TwoFactorConfig.SensitiveOperationWindowMinutes)); + } +} diff --git a/Core/Resgrid.Model/Security/PasskeyCeremony.cs b/Core/Resgrid.Model/Security/PasskeyCeremony.cs new file mode 100644 index 000000000..120e59efc --- /dev/null +++ b/Core/Resgrid.Model/Security/PasskeyCeremony.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model.Security +{ + /// + /// What a registration ceremony verified (passkey plan section 6.1 step 5). Library-neutral: the provider adapter maps + /// its own types onto this, so no WebAuthn library DTO reaches the domain model (plan section 4). + /// + public sealed class PasskeyRegistrationVerification + { + public bool Succeeded { get; init; } + + /// Value-free reason when verification failed (plan section 11): passkey_verification_failed. + public string FailureCode { get; init; } + + public byte[] CredentialId { get; init; } + public byte[] PublicKey { get; init; } + public int? Algorithm { get; init; } + public byte[] UserHandle { get; init; } + public long SignCount { get; init; } + public bool IsBackupEligible { get; init; } + public bool IsBackedUp { get; init; } + public IReadOnlyList Transports { get; init; } + public Guid Aaguid { get; init; } + public string AttestationFormat { get; init; } + public string Attachment { get; init; } + + public static PasskeyRegistrationVerification Failed(string code = "passkey_verification_failed") => new() { Succeeded = false, FailureCode = code }; + } + + /// The stored credential an assertion is verified against: this user's, bound to the asserting client. + public sealed class PasskeyAssertionCredential + { + public byte[] CredentialId { get; init; } + public byte[] PublicKey { get; init; } + public byte[] UserHandle { get; init; } + public long SignCount { get; init; } + } + + /// What an assertion ceremony verified. + public sealed class PasskeyAssertionVerification + { + public bool Succeeded { get; init; } + public string FailureCode { get; init; } + public long SignCount { get; init; } + public bool IsBackedUp { get; init; } + + public static PasskeyAssertionVerification Failed(string code = "passkey_verification_failed") => new() { Succeeded = false, FailureCode = code }; + } +} diff --git a/Core/Resgrid.Model/Security/PasskeyManagement.cs b/Core/Resgrid.Model/Security/PasskeyManagement.cs new file mode 100644 index 000000000..4c345d2c1 --- /dev/null +++ b/Core/Resgrid.Model/Security/PasskeyManagement.cs @@ -0,0 +1,217 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// Who is asking, from the session that request validation just accepted (passkey plan section 6.1). Nothing here is + /// taken from the client: a ceremony is bound to this user, session, client, generation and lock version. + /// + public sealed class PasskeyCaller + { + public string UserId { get; init; } + + /// The account name shown in the platform's passkey prompt. + public string UserName { get; init; } + + public string SessionId { get; init; } + + /// Set instead of for a second factor during sign-in (plan section 7.2). + public string LoginTransactionId { get; init; } + + /// + /// For a Responder approval (plan section 7.9): the request the approver's assertion answers. The ceremony is bound + /// to it; the approver's own session still identifies who approved. + /// + public string ApprovalRequestId { get; init; } + + public UserSessionClientApplication ClientApplication { get; init; } + + public long AuthenticationGeneration { get; init; } + + public long? SessionLockVersion { get; init; } + + public int? DepartmentId { get; init; } + + /// True for a shared-installation session: registration then asks for a roaming authenticator (plan section 6.5). + public bool SharedMode { get; init; } + + public SystemAuditSystems AuditSystem { get; init; } + + public string IpAddress { get; init; } + + public string SessionKey => MfaEvidence.TrackedSessionKey(SessionId); + + /// + /// What a ceremony's challenge is bound to: the login transaction during sign-in, the approval request for an + /// approval, otherwise the session. + /// + public AuthenticationChallengeParentKind ChallengeParentKind => + !string.IsNullOrWhiteSpace(LoginTransactionId) ? AuthenticationChallengeParentKind.LoginTransaction + : !string.IsNullOrWhiteSpace(ApprovalRequestId) ? AuthenticationChallengeParentKind.ApprovalRequest + : AuthenticationChallengeParentKind.Session; + + public string ChallengeParentId => + !string.IsNullOrWhiteSpace(LoginTransactionId) ? LoginTransactionId + : !string.IsNullOrWhiteSpace(ApprovalRequestId) ? ApprovalRequestId + : string.IsNullOrWhiteSpace(SessionId) ? null : SessionId; + + /// The same approver, answering one approval request. + public PasskeyCaller ForApprovalRequest(string approvalRequestId) => new() + { + UserId = UserId, + UserName = UserName, + SessionId = SessionId, + ApprovalRequestId = approvalRequestId, + ClientApplication = ClientApplication, + AuthenticationGeneration = AuthenticationGeneration, + SessionLockVersion = SessionLockVersion, + DepartmentId = DepartmentId, + SharedMode = SharedMode, + AuditSystem = AuditSystem, + IpAddress = IpAddress + }; + + /// The caller for a passkey second factor inside a login transaction; there is no session yet. + public static PasskeyCaller ForLoginTransaction(MfaLoginTransaction transaction, string userName, SystemAuditSystems auditSystem, string ipAddress) => + transaction == null + ? null + : new PasskeyCaller + { + UserId = transaction.UserId, + UserName = userName, + LoginTransactionId = transaction.MfaLoginTransactionId, + ClientApplication = (UserSessionClientApplication)transaction.ClientApplication, + AuthenticationGeneration = transaction.AuthenticationGeneration, + DepartmentId = transaction.DepartmentId, + AuditSystem = auditSystem, + IpAddress = ipAddress + }; + + /// The caller for a validated session; null when the request has no tracked session. + public static PasskeyCaller From(ProtectedGrantSessionContext session, string userId, string userName, int? departmentId, + SystemAuditSystems auditSystem, string ipAddress) => + session == null || string.IsNullOrWhiteSpace(session.SessionId) || string.IsNullOrWhiteSpace(userId) + ? null + : new PasskeyCaller + { + UserId = userId, + UserName = userName, + SessionId = session.SessionId, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + AuthenticationGeneration = session.AuthenticationGeneration, + SessionLockVersion = session.SessionLockVersion, + DepartmentId = departmentId, + SharedMode = session.SharedMode, + AuditSystem = auditSystem, + IpAddress = ipAddress + }; + } + + public enum PasskeyOutcome + { + Succeeded = 0, + + /// Passkeys are off on this deployment, or the client has no relying party. + Unavailable, + + /// The request has no tracked session to bind a ceremony or evidence to. + SessionRequired, + + /// No password or SSO verification for this session within the window. + ReauthenticationRequired, + + /// No accepted second factor for this session within the window. + StepUpRequired, + + /// TOTP and usable recovery codes must be set up before a passkey (plan section 6.1 item 2). + EnrollmentRequired, + + LimitReached, + TooManyRequests, + ChallengeExpired, + ChallengeConsumed, + TooManyAttempts, + VerificationFailed, + + /// The user has no usable passkey bound to the calling client. + NotRegisteredForClient, + + /// No active passkey with that id belongs to the caller. + NotFound, + + InvalidRequest, + ServiceUnavailable + } + + /// The machine-readable codes of the shared error vocabulary (workbook section 7.6) for each outcome. + public static class PasskeyOutcomes + { + public static string ErrorCode(PasskeyOutcome outcome) => outcome switch + { + PasskeyOutcome.Unavailable => "passkeys_unavailable", + PasskeyOutcome.SessionRequired => "session_required", + PasskeyOutcome.ReauthenticationRequired => "reauthentication_required", + PasskeyOutcome.StepUpRequired => "step_up_required", + PasskeyOutcome.EnrollmentRequired => "mfa_enrollment_required", + PasskeyOutcome.LimitReached => "passkey_limit_reached", + PasskeyOutcome.TooManyRequests => "too_many_attempts", + PasskeyOutcome.ChallengeExpired => "challenge_expired", + PasskeyOutcome.ChallengeConsumed => "challenge_consumed", + PasskeyOutcome.TooManyAttempts => "too_many_attempts", + PasskeyOutcome.VerificationFailed => "passkey_verification_failed", + PasskeyOutcome.NotRegisteredForClient => "passkey_not_registered_for_client", + PasskeyOutcome.NotFound => "passkey_not_found", + PasskeyOutcome.InvalidRequest => "invalid_request", + PasskeyOutcome.ServiceUnavailable => "service_unavailable", + _ => null + }; + } + + /// A started ceremony: the opaque request id and the WebAuthn options JSON for the client. + public sealed class PasskeyCeremonyStart + { + public PasskeyOutcome Outcome { get; init; } + public string RequestId { get; init; } + public string OptionsJson { get; init; } + + public bool Succeeded => Outcome == PasskeyOutcome.Succeeded; + + public static PasskeyCeremonyStart Of(PasskeyOutcome outcome) => new() { Outcome = outcome }; + } + + public sealed class PasskeyRegistrationResult + { + public PasskeyOutcome Outcome { get; init; } + public UserPasskey Passkey { get; init; } + + public bool Succeeded => Outcome == PasskeyOutcome.Succeeded; + + public static PasskeyRegistrationResult Of(PasskeyOutcome outcome) => new() { Outcome = outcome }; + } + + /// A verified assertion: which passkey, and when. The caller records it as evidence for its session. + public sealed class PasskeyAssertionResult + { + public PasskeyOutcome Outcome { get; init; } + public UserPasskey Passkey { get; init; } + public DateTime VerifiedOnUtc { get; init; } + + public bool Succeeded => Outcome == PasskeyOutcome.Succeeded; + + public static PasskeyAssertionResult Of(PasskeyOutcome outcome) => new() { Outcome = outcome }; + } + + /// + /// What a removal did (plan sections 6.1 item 8 and 6.4): how many passkeys, and how many sessions that signed in with + /// them were ended, including whether the caller's own session was one. + /// + public sealed class PasskeyRevocationResult + { + public PasskeyOutcome Outcome { get; init; } + public int Revoked { get; init; } + public int SessionsEnded { get; init; } + public bool CurrentSessionEnded { get; init; } + + public static PasskeyRevocationResult Of(PasskeyOutcome outcome) => new() { Outcome = outcome }; + } +} diff --git a/Core/Resgrid.Model/Security/ProtectedGrantBindingOutcome.cs b/Core/Resgrid.Model/Security/ProtectedGrantBindingOutcome.cs new file mode 100644 index 000000000..26e6f56a1 --- /dev/null +++ b/Core/Resgrid.Model/Security/ProtectedGrantBindingOutcome.cs @@ -0,0 +1,35 @@ +namespace Resgrid.Model.Security +{ + /// + /// Whether a validated Protected Data Grant belongs to the caller presenting it (passkey plan section 8.3). A version 1 + /// grant is checked for its user only; a version 2 grant must also match the validated session exactly. + /// + public enum ProtectedGrantBindingOutcome + { + Bound = 0, + + /// The grant was issued to another user, or there is no attended caller. + UserMismatch = 1, + + /// Version 2: the caller has no validated session, or a different one. + SessionMismatch = 2, + + /// Version 2: the grant was issued to another client application. + ClientMismatch = 3, + + /// Version 2: the account's authentication generation moved since issuance. + GenerationMismatch = 4, + + /// Version 2: the shared-session lock version differs (a lock invalidates older grants). + SessionLocked = 5, + + /// + /// Version 2: the grant's MFA method needs a credential or evidence state check that this build cannot perform yet, + /// so it is refused rather than trusted. + /// + MethodUnverifiable = 6, + + /// Version 2: the grant outlives its verification plus the department's current step-up window. + WindowExceeded = 7 + } +} diff --git a/Core/Resgrid.Model/Security/ProtectedGrantSessionContext.cs b/Core/Resgrid.Model/Security/ProtectedGrantSessionContext.cs new file mode 100644 index 000000000..34f147af4 --- /dev/null +++ b/Core/Resgrid.Model/Security/ProtectedGrantSessionContext.cs @@ -0,0 +1,60 @@ +namespace Resgrid.Model.Security +{ + /// + /// The facts of the caller's session that a version 2 Protected Data Grant must match (passkey plan section 8.3). They + /// come from the session record the request's session validation just loaded and checked, never from client-supplied + /// labels or unvalidated claims, so a missing value means "not proven" and a version 2 grant fails closed. + /// + public sealed class ProtectedGrantSessionContext + { + /// HttpContext.Items key under which session validation leaves the validated session's facts. + public const string HttpItemKey = "Resgrid.ProtectedGrantSession"; + + public string SessionId { get; init; } + + /// Numeric recorded on the session. + public int ClientApplication { get; init; } + + /// The account authentication generation the session was validated at. + public long AuthenticationGeneration { get; init; } + + /// The shared-session lock version; null for a personal session. + public long? SessionLockVersion { get; init; } + + /// True for a shared vehicle or workstation session (plan section 12.5). + public bool SharedMode { get; init; } + + /// When a shared session last locked; anything begun for this session before then is void. + public System.DateTime? SessionLockedOnUtc { get; init; } + + /// + /// When the caller's credential (cookie ticket or access token) was issued, as session validation checked it against + /// the account's credential cutoff. The broker repeats that check with the same value. + /// + public System.DateTime? CredentialIssuedOnUtc { get; init; } + + /// True when the session's first factor was an SSO sign-in (a grant's amr says fed, not pwd). + public bool FederatedFirstFactor { get; init; } + + /// When the session (for a shared session, its shift) ends; no grant outlives it (plan section 9.2). + public System.DateTime? SessionExpiresOnUtc { get; init; } + + /// The facts of a session that validation accepted; null when there is no tracked session. + public static ProtectedGrantSessionContext From(UserSession session, System.DateTime? credentialIssuedOnUtc) => + session == null || string.IsNullOrWhiteSpace(session.UserSessionId) + ? null + : new ProtectedGrantSessionContext + { + SessionId = session.UserSessionId, + ClientApplication = session.ClientApplication, + AuthenticationGeneration = session.AuthenticationGeneration, + SessionLockVersion = SharedSessionRules.LockVersionOf(session), + SharedMode = session.SharedMode, + SessionLockedOnUtc = session.SharedMode ? session.LockedOnUtc : null, + CredentialIssuedOnUtc = credentialIssuedOnUtc, + FederatedFirstFactor = session.AuthenticationMethod == (int)UserSessionAuthenticationMethod.OidcSso || + session.AuthenticationMethod == (int)UserSessionAuthenticationMethod.SamlSso, + SessionExpiresOnUtc = session.ExpiresOn == default ? null : System.DateTime.SpecifyKind(session.ExpiresOn, System.DateTimeKind.Utc) + }; + } +} diff --git a/Core/Resgrid.Model/Security/ProviderSignInTime.cs b/Core/Resgrid.Model/Security/ProviderSignInTime.cs new file mode 100644 index 000000000..ca39c60b0 --- /dev/null +++ b/Core/Resgrid.Model/Security/ProviderSignInTime.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.Security.Claims; + +namespace Resgrid.Model.Security +{ + /// + /// When the identity provider itself last authenticated the member, as a validated external identity carries it: an + /// id_token's auth_time (which .NET's JWT handler maps to ), or a + /// SAML assertion's AuthnInstant, which validation adds under the same auth_time claim. A shared + /// installation's sign-in must be fresh by it (plan section 12.5.2): an older provider sign-in there may be the last + /// operator's session, still in the installation's browser. + /// + public static class ProviderSignInTime + { + public const string ClaimType = "auth_time"; + + /// The provider's sign-in time in UTC, or null when the identity does not say. + public static DateTime? Read(ClaimsPrincipal principal) + { + var value = principal?.FindFirst(ClaimType)?.Value ?? principal?.FindFirst(ClaimTypes.AuthenticationInstant)?.Value; + return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 && seconds <= 253402300799 + ? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime + : null; + } + + /// The claim value for a sign-in time: seconds since the Unix epoch, as an id_token carries it. + public static string ClaimValue(DateTime authenticatedAtUtc) => + new DateTimeOffset(DateTime.SpecifyKind(authenticatedAtUtc, DateTimeKind.Utc)).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + + /// Within before now (allowing ), and not in the future. + public static bool IsFresh(DateTime? authenticatedAtUtc, DateTime nowUtc, TimeSpan window, TimeSpan skew) => + authenticatedAtUtc is { } at && at >= nowUtc - window - skew && at <= nowUtc + skew; + } +} diff --git a/Core/Resgrid.Model/Security/RelyingParty.cs b/Core/Resgrid.Model/Security/RelyingParty.cs new file mode 100644 index 000000000..dcc50f6eb --- /dev/null +++ b/Core/Resgrid.Model/Security/RelyingParty.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Resgrid.Model.Security +{ + /// + /// One client's WebAuthn relying party (workbook section 5). Every client has its own RP ID, which is what binds a + /// passkey to the app that registered it (plan section 1.1 item 11). + /// + public sealed class RelyingPartyDescriptor + { + public UserSessionClientApplication ClientApplication { get; init; } + + public string RpId { get; init; } + + /// Exact allowed origins: https origins under the RP ID and Android apk-key-hash origins. + public IReadOnlyCollection Origins { get; init; } + } + + /// Value-free readiness of the passkey configuration: problems name the client and rule, never secrets. + public sealed class PasskeyReadiness + { + public bool IsReady { get; init; } + + public IReadOnlyList Problems { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/SecurityNotice.cs b/Core/Resgrid.Model/Security/SecurityNotice.cs new file mode 100644 index 000000000..d2f9aaba2 --- /dev/null +++ b/Core/Resgrid.Model/Security/SecurityNotice.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; + +namespace Resgrid.Model.Security +{ + /// + /// A security notice to the account holder (passkey plan section 6.4): a factor was added, replaced, removed or used + /// for recovery, or someone else tried to use the account. The row is a user-level outbox entry (workbook section 8.3 + /// rule 7): it is written when the change commits, delivered at once where possible and retried until it is sent or + /// fails for good. It holds only what the notice says, never a credential, code, assertion, grant or protected data. + /// + public class SecurityNotice + { + public string SecurityNoticeId { get; set; } + + public string UserId { get; set; } + + /// . + public int Kind { get; set; } + + public DateTime OccurredOnUtc { get; set; } + + /// The app where it happened, when known (). + public int? ClientApplication { get; set; } + + /// The installation label from the server's session record, when known. + public string InstallationLabel { get; set; } + + /// Coarse origin (region and country), when known. + public string Region { get; set; } + + public int State { get; set; } + + public int Attempts { get; set; } + + public DateTime NextAttemptOnUtc { get; set; } + + /// The sender currently holding the notice; another sender skips it until the lease ends. + public string LeaseOwner { get; set; } + + public DateTime? LeaseUntilUtc { get; set; } + + public DateTime? SentOnUtc { get; set; } + + /// A value-free reason for the last failed delivery. + public string LastFailure { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + public SecurityNoticeKind NoticeKind => (SecurityNoticeKind)Kind; + public SecurityNoticeState NoticeState => (SecurityNoticeState)State; + } + + public enum SecurityNoticeKind + { + TotpEnabled = 1, + TotpReplaced = 2, + TotpDisabled = 3, + RecoveryCodesRegenerated = 4, + + /// A recovery code signed the user in or started factor recovery. + RecoveryCodeUsed = 5, + + PasskeyRegistered = 6, + PasskeyRemoved = 7, + ApprovalTurnedOn = 8, + ApprovalTurnedOff = 9, + + /// The user answered a Responder approval request with "I didn't request this". + ApprovalNotMe = 10, + + /// Approval requests were paused after repeated denials or expiries. + ApprovalSuspended = 11, + + FactorRecoveryCompleted = 12, + + /// An authenticator app or passkey was set up in a shared vehicle or workstation session (plan section 6.5). + SharedInstallationFactor = 13, + + /// The user reported a verification on their account as not theirs (plan section 6.5 "This wasn't me"). + ActivityReported = 14 + } + + public enum SecurityNoticeState + { + Pending = 0, + Sent = 1, + + /// Every attempt failed, or there is nowhere to send it. Logged and audited; the change it reports stands. + Failed = 2 + } + + public static class SecurityNotices + { + /// A coarse origin for a notice: region and country only, never the city. + public static string Region(string region, string country) => + string.Join(", ", new[] { region, country }.Where(part => !string.IsNullOrWhiteSpace(part)).Select(part => part.Trim())) is { Length: > 0 } text + ? text + : null; + } + + /// What to tell the account holder, from the server's own record of the change. + public sealed class SecurityNoticeRequest + { + public string UserId { get; init; } + public SecurityNoticeKind Kind { get; init; } + public DateTime? OccurredOnUtc { get; init; } + public UserSessionClientApplication? ClientApplication { get; init; } + public string InstallationLabel { get; init; } + public string Region { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/SessionEvents.cs b/Core/Resgrid.Model/Security/SessionEvents.cs new file mode 100644 index 000000000..0da5e67fa --- /dev/null +++ b/Core/Resgrid.Model/Security/SessionEvents.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Security +{ + /// + /// Realtime events for one signed-in session (passkey workbook section 7.4). Each connection with a session joins that + /// session's group, so an event reaches only the app that session belongs to. Events carry no secret and no personal + /// data; the app reads anything else through its own authenticated API calls. + /// + public static class SessionEvents + { + /// A Responder approval the session asked for was decided: { approvalRequestId, state }. + public const string MfaApprovalChanged = "mfaApprovalChanged"; + + /// + /// Internal: the session locked or ended, so every host closes its open connections now instead of at the next sweep. + /// Never sent to a client. + /// + public const string SessionClosed = "sessionClosed"; + + public static string GroupFor(string sessionId) => "session:" + sessionId; + + public static bool IsKnown(string name) => name is MfaApprovalChanged or SessionClosed; + } + + public sealed class SessionEventMessage + { + public string Name { get; set; } + public string ApprovalRequestId { get; set; } + public string State { get; set; } + } + + /// Publishes a session event to the hosts that hold realtime connections. Never throws; delivery is best effort. + public interface ISessionEventPublisher + { + Task PublishAsync(string sessionId, SessionEventMessage message, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Security/SharedSessionAudit.cs b/Core/Resgrid.Model/Security/SharedSessionAudit.cs new file mode 100644 index 000000000..2f9bd3e17 --- /dev/null +++ b/Core/Resgrid.Model/Security/SharedSessionAudit.cs @@ -0,0 +1,26 @@ +namespace Resgrid.Model.Security +{ + /// + /// The value-free text of shared-session audits (passkey plan section 13): the operator is the audit's user, and the + /// installation label is only a label. Nothing here carries a token, code or anything from the previous operator. + /// + public static class SharedSessionAudit + { + /// The last eight characters of a session id, enough for support to match it. + public static string SessionSuffix(string sessionId) => + string.IsNullOrWhiteSpace(sessionId) || sessionId.Length <= 8 ? sessionId : sessionId.Substring(sessionId.Length - 8); + + public static string Describe(string action, UserSession session, string detail = null) => + $"Shared session {action}. Client={(UserSessionClientApplication)session.ClientApplication}; " + + $"Installation={Bound(session.DeviceName, 128)}; LockVersion={session.LockVersion}" + + (string.IsNullOrWhiteSpace(detail) ? "." : $"; {Bound(detail, 64)}."); + + private static string Bound(string value, int maximumLength) + { + if (string.IsNullOrWhiteSpace(value)) + return "Unknown"; + var sanitized = value.Replace("\r", " ").Replace("\n", " ").Replace(";", ",").Trim(); + return sanitized.Length <= maximumLength ? sanitized : sanitized.Substring(0, maximumLength); + } + } +} diff --git a/Core/Resgrid.Model/Security/SharedSessions.cs b/Core/Resgrid.Model/Security/SharedSessions.cs new file mode 100644 index 000000000..3b5c81675 --- /dev/null +++ b/Core/Resgrid.Model/Security/SharedSessions.cs @@ -0,0 +1,83 @@ +using System; + +namespace Resgrid.Model.Security +{ + public enum SharedSessionOutcome + { + Succeeded = 0, + + /// The session is personal; lock, unlock and end shift apply only to shared sessions. + NotShared, + + /// Unlock was asked for a session that is not locked. + NotLocked, + + /// The lock version is not the one the caller expected: it locked again, or another unlock won. + LockChanged, + + /// The session ended, expired, or belongs to someone else. The operator signs in normally. + SessionEnded, + + /// The department now requires SSO and this session began with a password: sign in again with SSO. + SsoReauthenticationRequired, + + ServiceUnavailable + } + + /// Who is asking, for the audit; the operator is always the session's own user. + public sealed class SharedSessionRequestInfo + { + public string UserName { get; init; } + public string IpAddress { get; init; } + public string CorrelationId { get; init; } + + /// The host the operator used: the API for the apps, the Website for Core Web. + public SystemAuditSystems AuditSystem { get; init; } = SystemAuditSystems.Api; + } + + /// A shared session's state as its locked or unlocked client may read it (plan section 12.5.5). + public sealed class SharedSessionStatus + { + public bool Shared { get; init; } + public bool Locked { get; init; } + public long LockVersion { get; init; } + public SharedSessionLockReason? LockReason { get; init; } + public int IdleLockMinutes { get; init; } + + /// When it locks unless the operator does something first; null while locked or personal. + public DateTime? IdleLocksOnUtc { get; init; } + + /// When the shift ends whatever the activity; null for a personal session. + public DateTime? ShiftEndsOnUtc { get; init; } + + public UserSessionClientApplication ClientApplication { get; init; } + public string InstallationLabel { get; init; } + } + + public sealed class SharedSessionTransition + { + public SharedSessionOutcome Outcome { get; init; } + + /// The session's lock version after the transition. + public long LockVersion { get; init; } + + public bool Succeeded => Outcome == SharedSessionOutcome.Succeeded; + + public static SharedSessionTransition Of(SharedSessionOutcome outcome, long lockVersion = 0) => new() { Outcome = outcome, LockVersion = lockVersion }; + } + + public static class SharedSessionOutcomes + { + /// The value-free error code for an outcome (workbook section 7.6); null on success. + public static string ErrorCode(SharedSessionOutcome outcome) => outcome switch + { + SharedSessionOutcome.Succeeded => null, + SharedSessionOutcome.NotShared => "not_shared_session", + SharedSessionOutcome.NotLocked => "shared_session_not_locked", + SharedSessionOutcome.LockChanged => "shared_session_lock_changed", + SharedSessionOutcome.SessionEnded => "session_revoked", + SharedSessionOutcome.SsoReauthenticationRequired => "sso_reauthentication_required", + _ => "service_unavailable" + }; + } +} diff --git a/Core/Resgrid.Model/Security/SsoLoginTransaction.cs b/Core/Resgrid.Model/Security/SsoLoginTransaction.cs new file mode 100644 index 000000000..12d35ed07 --- /dev/null +++ b/Core/Resgrid.Model/Security/SsoLoginTransaction.cs @@ -0,0 +1,270 @@ +using System; +using System.Security.Claims; + +namespace Resgrid.Model.Security +{ + /// + /// A server-brokered SSO sign-in (passkey plan section 7.7.2; workbook section 7.3). The server runs the IdP round trip, + /// validates the result, and hands the client only a one-time sso_code at a registered return target; the client + /// redeems it with its PKCE verifier. The row is the authority and every change is one compare-and-set. It stores + /// hashes of the IdP state, nonce and code, the client's S256 challenge, and the IdP PKCE verifier encrypted; never an + /// IdP token or assertion. + /// + public class SsoLoginTransaction + { + /// The step-up that completes a password sign-in's login MFA transaction. + public const string LoginOperation = "login"; + + /// + /// The step-up that unlocks a locked shared session (plan section 12.5.3). It follows the + /// sign-in rules, is bound to that session, and counts only if begun during the lock it answers. + /// + public const string SharedUnlockOperation = "shared_unlock"; + + /// The public sso_transaction_id. Knowing it alone redeems nothing. + public string SsoLoginTransactionId { get; set; } + + /// SHA-256 of the state (OIDC) or RelayState (SAML) sent to the IdP. + public byte[] StateHash { get; set; } + + public int Purpose { get; set; } + + public int DepartmentId { get; set; } + + public string DepartmentSsoConfigId { get; set; } + + public int ProviderType { get; set; } + + public int ClientApplication { get; set; } + + /// Client-reported platform (ios, android, web, electron); a label only. + public string Platform { get; set; } + + /// + /// From a shared installation (plan section 12.5.2): the callback requires the provider's own sign-in to be fresh, so + /// the next operator cannot ride on the last one's provider session. + /// + public bool SharedInstallation { get; set; } + + /// The registered return target the one-time code goes to. + public string ReturnTarget { get; set; } + + /// The client's own CSRF value, echoed back on return. + public string ClientState { get; set; } + + /// The client's PKCE S256 challenge; redemption must present its verifier. + public string CodeChallenge { get; set; } + + /// SHA-256 of the OIDC nonce. + public byte[] NonceHash { get; set; } + + /// The server's PKCE verifier toward the IdP, encrypted with the system key. + public string EncryptedIdpCodeVerifier { get; set; } + + /// The SAML AuthnRequest ID the response must answer (InResponseTo). + public string SamlRequestId { get; set; } + + /// For a step-up: the operation it serves (MfaStepUpOperations), or null for a login transaction. + public string Operation { get; set; } + + /// For a step-up that completes a password sign-in: the login MFA transaction's row id. + public string LoginTransactionId { get; set; } + + /// The provider step-up mapping version the request carried; null when none was requested. + public long? FederatedMappingVersion { get; set; } + + /// The returned value the mapping counted as MFA (kind:value); null when there was none. + public string FederatedMfaValue { get; set; } + + // Reauthentication and step-up bind to the signed-in session (or login transaction) and account they began from. + public string SessionId { get; set; } + public string ExpectedUserId { get; set; } + public long? AuthenticationGeneration { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + public DateTime ExpiresOnUtc { get; set; } + + public int State { get; set; } + + // Set by the one successful IdP callback. + public string UserId { get; set; } + + /// When the IdP authenticated the user (auth_time or AuthnInstant), else the callback time. + public DateTime? AuthenticatedOnUtc { get; set; } + + public byte[] CodeHash { get; set; } + public DateTime? CodeExpiresOnUtc { get; set; } + public DateTime? RedeemedOnUtc { get; set; } + + /// Value-free reason a callback failed, for audit. + public string FailureCode { get; set; } + + public SsoTransactionPurpose TransactionPurpose => (SsoTransactionPurpose)Purpose; + public SsoLoginTransactionState TransactionState => (SsoLoginTransactionState)State; + } + + public enum SsoTransactionPurpose + { + Login = 1, + + /// Fresh first-factor proof for the signed-in session (plan sections 6.2 and 7.7.2 item 9). + Reauthentication = 2, + + /// Provider step-up for an operation, or to complete a password sign-in (plan section 7.8). + StepUp = 3, + + /// Provider step-up for a Protected Data Grant, redeemed only at DataProtection/CompleteFederated. + AdpStepUp = 4, + + /// The managing member's test of a provider step-up mapping; it enables nothing until it succeeds. + MappingTest = 5 + } + + public enum SsoLoginTransactionState + { + Pending = 0, + + /// The IdP result was validated and a one-time code issued. + Authenticated = 1, + + Redeemed = 2, + + Failed = 3 + } + + public enum SsoBrokerOutcome + { + Succeeded = 0, + Unavailable, + InvalidRequest, + ReturnTargetNotAllowed, + TransactionInvalid, + Expired, + AlreadyUsed, + VerificationFailed, + IdentityMismatch, + AccessDenied, + ReauthenticationNotFresh, + + /// The provider's response carried no value the department's mapping counts as MFA. + FederatedNotSatisfied, + + ServiceUnavailable + } + + public static class SsoBrokerOutcomes + { + public static string ErrorCode(SsoBrokerOutcome outcome) => outcome switch + { + SsoBrokerOutcome.Unavailable => "sso_unavailable", + SsoBrokerOutcome.InvalidRequest => "invalid_request", + SsoBrokerOutcome.ReturnTargetNotAllowed => "return_target_not_allowed", + SsoBrokerOutcome.TransactionInvalid => "sso_transaction_invalid", + SsoBrokerOutcome.Expired => "sso_transaction_expired", + SsoBrokerOutcome.AlreadyUsed => "sso_transaction_invalid", + SsoBrokerOutcome.VerificationFailed => "sso_verification_failed", + SsoBrokerOutcome.IdentityMismatch => "federated_identity_mismatch", + SsoBrokerOutcome.AccessDenied => "access_denied", + SsoBrokerOutcome.ReauthenticationNotFresh => "reauthentication_not_fresh", + SsoBrokerOutcome.FederatedNotSatisfied => "federated_mfa_not_satisfied", + SsoBrokerOutcome.ServiceUnavailable => "service_unavailable", + _ => null + }; + + public static string PurposeName(SsoTransactionPurpose purpose) => purpose switch + { + SsoTransactionPurpose.Login => "login", + SsoTransactionPurpose.Reauthentication => "reauth", + SsoTransactionPurpose.StepUp => "step_up", + SsoTransactionPurpose.AdpStepUp => "adp_step_up", + SsoTransactionPurpose.MappingTest => "mapping_test", + _ => null + }; + + public static SsoTransactionPurpose? PurposeFrom(string name) => (name ?? "login").Trim().ToLowerInvariant() switch + { + "login" => SsoTransactionPurpose.Login, + "reauth" => SsoTransactionPurpose.Reauthentication, + "step_up" => SsoTransactionPurpose.StepUp, + "adp_step_up" => SsoTransactionPurpose.AdpStepUp, + _ => null + }; + } + + /// Everything Sso/Begin needs; the department is already resolved and the caller already known. + public sealed class SsoBeginRequest + { + public int DepartmentId { get; init; } + public string DepartmentCode { get; init; } + public SsoTransactionPurpose Purpose { get; init; } + public UserSessionClientApplication ClientApplication { get; init; } + public string Platform { get; init; } + public string ReturnTarget { get; init; } + public string ClientState { get; init; } + public string CodeChallenge { get; init; } + public string CodeChallengeMethod { get; init; } + + // Reauthentication, step-up and mapping tests: the validated session asking. + public string SessionId { get; init; } + public string UserId { get; init; } + public long? AuthenticationGeneration { get; init; } + + /// Step-up: the operation it serves, or login with . + public string Operation { get; init; } + + /// Step-up that completes a password sign-in: the login MFA transaction (with ). + public string LoginTransactionId { get; init; } + + /// + /// A shared vehicle or workstation installation (plan section 12.5.2): the provider must authenticate the operator + /// again (OIDC prompt=login with max_age=0; SAML ForceAuthn), and the callback refuses a provider + /// sign-in that is not fresh, so the previous operator's provider session never signs the next one in. + /// + public bool SharedInstallation { get; init; } + } + + public sealed class SsoBeginResult + { + public SsoBrokerOutcome Outcome { get; init; } + public string AuthorizeUrl { get; init; } + public string TransactionId { get; init; } + public int ExpiresInSeconds { get; init; } + + public bool Succeeded => Outcome == SsoBrokerOutcome.Succeeded; + + public static SsoBeginResult Of(SsoBrokerOutcome outcome) => new() { Outcome = outcome }; + } + + /// Where the IdP callback sends the browser: the registered return target, with a code or an error. + public sealed class SsoCallbackResult + { + public SsoBrokerOutcome Outcome { get; init; } + + /// Null when no trusted return target is known (an unknown or foreign state): the callback shows an error. + public string RedirectUrl { get; init; } + + public bool Succeeded => Outcome == SsoBrokerOutcome.Succeeded; + } + + public sealed class SsoRedemptionResult + { + public SsoBrokerOutcome Outcome { get; init; } + public SsoLoginTransaction Transaction { get; init; } + + public bool Succeeded => Outcome == SsoBrokerOutcome.Succeeded; + + public static SsoRedemptionResult Of(SsoBrokerOutcome outcome, SsoLoginTransaction transaction = null) => + new() { Outcome = outcome, Transaction = transaction }; + } + + /// A validated IdP identity from a brokered response, with when the IdP authenticated the user if it said. + public sealed class SsoIdentityAssertion + { + public ClaimsPrincipal Principal { get; init; } + public DateTime? AuthenticatedAtUtc { get; init; } + + /// The assertion's AuthnContextClassRef values, for provider step-up mappings. + public System.Collections.Generic.IReadOnlyCollection AuthnContextClassRefs { get; init; } + } +} diff --git a/Core/Resgrid.Model/Security/StagedAuthenticatorKey.cs b/Core/Resgrid.Model/Security/StagedAuthenticatorKey.cs new file mode 100644 index 000000000..9dfb41cc8 --- /dev/null +++ b/Core/Resgrid.Model/Security/StagedAuthenticatorKey.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; + +namespace Resgrid.Model.Security +{ + /// + /// A new authenticator key waiting for its first code (passkey plan section 6.2). It is stored with the time it was + /// staged so it expires: staging is authorized by a fresh first factor, and that authorization must not last forever. + /// + public static class StagedAuthenticatorKey + { + /// Where the staged key is kept: an Identity authentication token, apart from the active key. + public const string LoginProvider = "[ResgridMfa]"; + public const string TokenName = "StagedAuthenticatorKey"; + + private sealed record Stored(string Key, DateTime StagedOnUtc); + + public static string Serialize(string key, DateTime stagedOnUtc) + => JsonSerializer.Serialize(new Stored(key, DateTime.SpecifyKind(stagedOnUtc, DateTimeKind.Utc))); + + /// + /// Returns the staged key while it is within ; null when there is none, it is malformed, + /// it has expired, or it claims to be from the future. + /// + public static string ReadUsableKey(string stored, DateTime utcNow, TimeSpan lifetime) + { + if (string.IsNullOrWhiteSpace(stored) || !stored.TrimStart().StartsWith('{')) + return null; + + Stored value; + try + { + value = JsonSerializer.Deserialize(stored); + } + catch (JsonException) + { + return null; + } + + if (value == null || string.IsNullOrWhiteSpace(value.Key)) + return null; + + var age = utcNow - value.StagedOnUtc.ToUniversalTime(); + return age >= TimeSpan.FromSeconds(-30) && age <= lifetime ? value.Key : null; + } + } +} diff --git a/Core/Resgrid.Model/Security/UserPasskey.cs b/Core/Resgrid.Model/Security/UserPasskey.cs new file mode 100644 index 000000000..8396e3c0d --- /dev/null +++ b/Core/Resgrid.Model/Security/UserPasskey.cs @@ -0,0 +1,95 @@ +using System; + +namespace Resgrid.Model.Security +{ + /// + /// A registered passkey's public credential (passkey plan section 5.1). It belongs to one user and is bound to the one + /// client application whose relying party registered it; an assertion from any other client is refused. Resgrid holds + /// only the public key: never a private key or biometric data. Revocation is durable and advances the state version. + /// + public class UserPasskey + { + /// Server-generated row id, independent of the credential id. + public string UserPasskeyId { get; set; } + + public string UserId { get; set; } + + /// The the credential is bound to. Immutable. + public int ClientApplication { get; set; } + + /// The relying party the credential was registered against. + public string RpId { get; set; } + + /// The complete credential id, compared byte for byte after the hash lookup. + public byte[] CredentialId { get; set; } + + /// SHA-256 of the credential id: the unique, indexed lookup key within the RP. + public byte[] CredentialIdHash { get; set; } + + /// The verified COSE public key. + public byte[] PublicKey { get; set; } + + /// COSE algorithm identifier of the public key (for example -7 for ES256), when known. + public int? Algorithm { get; set; } + + /// The opaque per-account, per-RP user handle given to the authenticator. + public byte[] UserHandle { get; set; } + + public long SignCount { get; set; } + + public bool IsBackupEligible { get; set; } + + public bool IsBackedUp { get; set; } + + /// Comma-separated transports the authenticator reported; a hint only. + public string Transports { get; set; } + + public string Aaguid { get; set; } + + public string AttestationFormat { get; set; } + + /// User-managed name, bounded and escaped on display. + public string DisplayName { get; set; } + + public DateTime CreatedOnUtc { get; set; } + + // Registration context: what the server observed about the registering session. Not proof of a device. + public string RegistrationPlatform { get; set; } + public string RegistrationInstallation { get; set; } + public string RegistrationUserAgentFamily { get; set; } + + /// Client-reported authenticator attachment ("platform" or "cross-platform"); a hint only. + public string RegistrationAttachment { get; set; } + + public bool RegisteredInSharedMode { get; set; } + + public DateTime? LastUsedOnUtc { get; set; } + public int? LastUsedClientApplication { get; set; } + public string LastUsedInstallation { get; set; } + public bool LastUsedInSharedMode { get; set; } + + /// Responder credentials only: whether it may approve other apps' requests (plan section 7.9). + public bool ApprovalEnabled { get; set; } + + public DateTime? RevokedOnUtc { get; set; } + public int? RevocationReason { get; set; } + public string RevokedByUserId { get; set; } + + /// Advanced on revocation and on every change a grant or evidence could depend on. + public long StateVersion { get; set; } + + public bool IsActive => RevokedOnUtc == null; + + /// The MFA evidence factor reference for a passkey, so its removal can retire what it verified. + public static string FactorReferenceFor(string userPasskeyId) => + string.IsNullOrWhiteSpace(userPasskeyId) ? null : "passkey:" + userPasskeyId; + } + + public enum PasskeyRevocationReason + { + RemovedByUser = 1, + RemovedAllForClient = 2, + FactorRecovery = 3, + AccountDeactivated = 4 + } +} diff --git a/Core/Resgrid.Model/Security/UserSessionContracts.cs b/Core/Resgrid.Model/Security/UserSessionContracts.cs index 3af244337..224985963 100644 --- a/Core/Resgrid.Model/Security/UserSessionContracts.cs +++ b/Core/Resgrid.Model/Security/UserSessionContracts.cs @@ -26,6 +26,18 @@ public class SessionIssueContext public string City { get; set; } public string UserAgent { get; set; } public bool IsLegacyAdopted { get; set; } + + /// The second factor the sign-in verified (), when it verified one. + public MfaEvidenceMethod? LoginMfaMethod { get; set; } + + /// The factor instance the sign-in used, such as passkey:{id}. + public string LoginMfaFactorReference { get; set; } + + /// + /// The installation asked for a shared session (X-Resgrid-Shared-Installation). Honored only while + /// PasskeyConfig.SharedDeviceModeEnabled is on; a department that requires shared mode needs no request. + /// + public bool SharedModeRequested { get; set; } } public class SessionPrincipalContext @@ -77,11 +89,20 @@ public class SessionValidationResult public string FailureCode { get; set; } public UserSession Session { get; set; } + /// + /// A shared session that is otherwise valid but locked (plan section 12.5.3). It is still invalid for every caller; + /// only the locked-session endpoints (status, unlock, lock, end shift) accept it, and they read . + /// + public bool IsLocked { get; set; } + public static SessionValidationResult Valid(UserSession session = null, bool canAdoptLegacy = false) => new SessionValidationResult { IsValid = true, CanAdoptLegacy = canAdoptLegacy, Session = session }; public static SessionValidationResult Invalid(string code) => new SessionValidationResult { IsValid = false, FailureCode = code }; + + public static SessionValidationResult Locked(UserSession session) => + new SessionValidationResult { IsValid = false, IsLocked = true, FailureCode = SharedSessionRules.LockedFailureCode, Session = session }; } public class UserSessionSummary @@ -106,6 +127,11 @@ public class UserSessionSummary public string UserAgent { get; set; } public bool IsLegacyAdopted { get; set; } public bool IsCurrent { get; set; } + + /// A shared vehicle or workstation session (plan section 12.5.5 support view). + public bool SharedMode { get; set; } + + public bool IsLocked { get; set; } } public class RevocationResult diff --git a/Core/Resgrid.Model/Services/IAdpStepUpService.cs b/Core/Resgrid.Model/Services/IAdpStepUpService.cs new file mode 100644 index 000000000..eb538d655 --- /dev/null +++ b/Core/Resgrid.Model/Services/IAdpStepUpService.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The one orchestration every Protected Data Grant goes through (passkey plan sections 8.1 and 9.2), on Web and the API + /// alike: check the caller's session, verify or accept the factor, resolve the department's current policy and method + /// switches, compute the absolute expiry from the verification time, record the evidence, and sign. A method other than + /// TOTP needs a version 2 grant bound to a tracked session. Missing signing material is an unavailable operation, never + /// a token-less success. + /// + public interface IAdpStepUpService + { + /// The methods the caller can use for this department's protected data now, and which to show first. + Task GetMethodChoiceAsync(AdpStepUpCaller caller, bool totpEnrolled, CancellationToken cancellationToken = default); + + /// Assertion options limited to the caller's passkeys for its own client, bound to its session. + Task BeginPasskeyAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default); + + /// Verifies the assertion, records AdpStepUp evidence and issues a passkey grant. + Task CompletePasskeyAsync(AdpStepUpCaller caller, string requestId, string credentialJson, + CancellationToken cancellationToken = default); + + /// + /// Issues a totp grant for an authenticator code the caller has just verified (the code check belongs to the + /// Identity surface that shares the account lockout), and records the evidence. + /// + Task IssueForTotpAsync(AdpStepUpCaller caller, DateTime verifiedOnUtc, CancellationToken cancellationToken = default); + + /// Asks the user's Responder to approve access to this department's protected data (plan section 7.9). + Task RequestApprovalAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default); + + /// Uses an approved ADP request once and issues a passkey_approval grant from its decision time. + Task CompleteApprovalAsync(AdpStepUpCaller caller, string approvalRequestId, CancellationToken cancellationToken = default); + + /// Redeems a brokered adp_step_up round trip and issues a federated grant (plan section 7.8). + Task CompleteFederatedAsync(AdpStepUpCaller caller, string ssoTransactionId, string code, string codeVerifier, + CancellationToken cancellationToken = default); + + /// + /// A grant without a second factor for a client the department exempted from the prompt (plan section 3.3). It is + /// never proof of MFA; for any other client. + /// + Task IssueExemptAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default); + + /// + /// A grant from this session's own recent second factor, with no new prompt (plan section 9.1): sign-in evidence where the + /// department accepts reusing it, shared-unlock evidence likewise, or earlier protected-data evidence for this department. + /// It expires from the original verification. when nothing qualifies. + /// + Task IssueFromRecentEvidenceAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IAuthenticationChallengeService.cs b/Core/Resgrid.Model/Services/IAuthenticationChallengeService.cs new file mode 100644 index 000000000..f4229ff08 --- /dev/null +++ b/Core/Resgrid.Model/Services/IAuthenticationChallengeService.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Issues and spends WebAuthn ceremony challenges (passkey plan section 5.2). The caller verifies the ceremony between + /// and ; only one completion can ever consume a + /// challenge, on any node, and a storage fault never counts as success. + /// + public interface IAuthenticationChallengeService + { + /// + /// Stores a new pending challenge for the binding with the given server options. Returns null when the user already + /// holds the maximum number of pending challenges. + /// + Task CreateAsync(AuthenticationChallengeBinding binding, string rpId, string optionsJson, + CancellationToken cancellationToken = default); + + /// Loads the challenge and checks it is pending, unexpired and issued to exactly this binding. + Task GetForCompletionAsync(string challengeId, AuthenticationChallengeBinding binding, + CancellationToken cancellationToken = default); + + /// Spends the challenge after a successful verification; true for exactly one caller. + Task TryConsumeAsync(string challengeId, CancellationToken cancellationToken = default); + + /// Counts a failed verification against the challenge. + Task RecordFailedAttemptAsync(string challengeId, CancellationToken cancellationToken = default); + + /// Cancels every pending challenge for the user. + Task CancelPendingForUserAsync(string userId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IBrokerSessionAssertionService.cs b/Core/Resgrid.Model/Services/IBrokerSessionAssertionService.cs new file mode 100644 index 000000000..2d7eca75d --- /dev/null +++ b/Core/Resgrid.Model/Services/IBrokerSessionAssertionService.cs @@ -0,0 +1,30 @@ +using System; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Mints and validates broker session assertions (passkey workbook section 6.2) with a dedicated ES256 certificate + /// that is neither the grant key nor an OpenIddict key. Web and API hold the private key and mint; the broker holds + /// the public certificate and validates. Missing key material means "cannot mint" or "cannot validate", never success. + /// + public interface IBrokerSessionAssertionService + { + bool CanMint { get; } + + bool CanValidate { get; } + + /// + /// Signs an assertion for the given facts. The service sets the id, issue time and expiry; the caller supplies the + /// user, session, generation, department, client, lock version and request digest. + /// + string Mint(BrokerSessionAssertion facts); + + /// + /// Verifies signature, issuer, audience, lifetime and the request digest, and returns the asserted facts. Anything + /// but must deny the request. + /// + BrokerSessionAssertionOutcome Validate(string token, string expectedRequestDigest, out BrokerSessionAssertion assertion, + DateTime? utcNow = null); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentSsoService.cs b/Core/Resgrid.Model/Services/IDepartmentSsoService.cs index 0991a23de..4b02c4750 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSsoService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSsoService.cs @@ -41,6 +41,13 @@ public interface IDepartmentSsoService /// Saves (creates or updates) the security policy for a department. Task SaveSecurityPolicyAsync(DepartmentSecurityPolicy policy, CancellationToken cancellationToken = default); + /// + /// Saves the policy and, atomically with it, advances MfaPolicyVersion when the sign-in MFA rules change and the ADP + /// PolicyEpoch when the rules for Protected Data Grants change (passkey plan section 10.1). + /// is recorded on the ADP policy when its epoch advances. + /// + Task SaveSecurityPolicyAsync(DepartmentSecurityPolicy policy, string changedByUserId, CancellationToken cancellationToken = default); + // ── Token Validation & User Provisioning ────────────────────────────── /// @@ -51,6 +58,33 @@ public interface IDepartmentSsoService /// Task ValidateExternalTokenAsync(int departmentId, SsoProviderType providerType, string externalToken, string departmentCode, CancellationToken cancellationToken = default); + /// + /// Validates a SAML response to a brokered AuthnRequest (passkey plan section 7.7.2): every legacy check plus the + /// signed InResponseTo binding to , so an unsolicited or another request's + /// response is refused. Returns the identity and the IdP's AuthnInstant, or null. + /// + Task ValidateBrokeredSamlResponseAsync(int departmentId, string base64SamlResponse, string departmentCode, + string expectedRequestId, CancellationToken cancellationToken = default); + + /// + /// The department's enabled SSO configuration whose provider step-up mapping has passed its test at the current + /// version (plan section 7.8); null when there is none, which means provider step-up is unavailable there. + /// + Task GetTestedFederatedMfaConfigAsync(int departmentId, CancellationToken cancellationToken = default); + + /// Whether the user can use provider step-up in the department: a tested mapping and an SSO-linked membership. + Task IsFederatedMfaAvailableAsync(int departmentId, string userId, CancellationToken cancellationToken = default); + + /// Records a passed mapping test for exactly the version tested; false when the mapping changed meanwhile. + Task RecordFederatedMfaTestAsync(string departmentSsoConfigId, long version, string userId, CancellationToken cancellationToken = default); + + /// + /// The Resgrid user already linked to this external identity in the department, without linking, provisioning or + /// writing anything; null when there is none. Used where the account must not change, such as reauthentication. + /// + Task FindLinkedUserIdAsync(int departmentId, ClaimsPrincipal externalClaims, DepartmentSsoConfig config, + CancellationToken cancellationToken = default); + /// /// Provisions a new user or links an existing user from the supplied . /// diff --git a/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs b/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs index 79cace4e4..275c3dae9 100644 --- a/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs +++ b/Core/Resgrid.Model/Services/IExternalIdentityLinkService.cs @@ -9,6 +9,9 @@ public interface IExternalIdentityLinkService Task GetBySubjectAsync(string departmentSsoConfigId, string externalSubject, CancellationToken cancellationToken = default); Task SaveAsync(UserExternalIdentityLink link, CancellationToken cancellationToken = default); Task GetSsoManagementStateAsync(string userId, CancellationToken cancellationToken = default); + + /// The user's active links to department identity providers (plan section 6.5, read-only). + Task> GetActiveLinksAsync(string userId, CancellationToken cancellationToken = default); Task IsLocalLoginAllowedAsync(string userId, CancellationToken cancellationToken = default); Task IsLocalLoginAllowedAsync(string userId, int departmentId, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Services/IFactorRecoveryService.cs b/Core/Resgrid.Model/Services/IFactorRecoveryService.cs new file mode 100644 index 000000000..8f87a159c --- /dev/null +++ b/Core/Resgrid.Model/Services/IFactorRecoveryService.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Restricted factor recovery (passkey plan sections 5.4 and 6.3). A login transaction (a verified first factor) and a + /// recovery code open it; the secret it returns permits only status, replacement setup, completion and cancellation, + /// and grants no ordinary or ADP access. This service owns the transaction's state; verifying the recovery code and the + /// replacement authenticator is the caller's job. + /// + public interface IFactorRecoveryService + { + /// Recovery starts from a login transaction, so it follows that gate. + bool IsEnabled { get; } + + /// Opens a recovery for the login transaction whose recovery code the caller has just spent; the secret is returned once. + Task BeginAsync(MfaLoginTransaction login, CancellationToken cancellationToken = default); + + /// The pending recovery for a secret, for this client, unexpired, under its attempt limit, with the account's generation unchanged. + Task OpenAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default); + + Task RecordFailedAttemptAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default); + + /// Completes the recovery once; the caller then commits the replacement. + Task TryCompleteAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default); + + /// Ends a pending recovery; the recovery code that opened it stays spent. + Task CancelAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaAccountCleanupService.cs b/Core/Resgrid.Model/Services/IMfaAccountCleanupService.cs new file mode 100644 index 000000000..a8bdc2af6 --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaAccountCleanupService.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// What account deletion does to sign-in factors (passkey plan section 6.4): passkeys are revoked (their rows stay as + /// tombstones, so a credential can never be registered again), evidence is retired, pending challenges and approval + /// requests end, and queued notices and recovery transactions are removed. The authenticator key, recovery codes and + /// TOTP state are removed with the identity itself. + /// + public interface IMfaAccountCleanupService + { + Task RemoveForDeletedAccountAsync(string userId, string actorUserId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaActivityService.cs b/Core/Resgrid.Model/Services/IMfaActivityService.cs new file mode 100644 index 000000000..987ed269a --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaActivityService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The account's recent verifications (plan section 6.5). Successes are recorded where evidence is written; denials where + /// each surface refuses a factor. Recording never fails the verification it describes. + /// + public interface IMfaActivityService + { + Task RecordAsync(MfaActivityEntry entry, CancellationToken cancellationToken = default); + + /// The user's activity within retention, newest first. + Task> GetRecentAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// "This wasn't me": marks the user's own activity reported, ends the session it opened or served (unless it is the + /// reporting session), audits, and sends the security notice (plan section 6.4). + /// + Task ReportAsync(string userId, string mfaActivityId, string reportingSessionId, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaApprovalService.cs b/Core/Resgrid.Model/Services/IMfaApprovalService.cs new file mode 100644 index 000000000..79a73a33b --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaApprovalService.cs @@ -0,0 +1,72 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Responder approval (passkey plan section 7.9; workbook section 7.4): another app asks, the user approves in their own + /// Responder with Responder's passkey after typing the number shown on the requesting screen, and the requester + /// completes through its own login transaction or session. Nothing is issued to Responder. + /// + public interface IMfaApprovalService + { + /// Whether the approval gate is on. + bool IsEnabled { get; } + + /// + /// Whether could ask this user for approval now: the gate is on, the client is not + /// Responder, and the user has an eligible Responder (an active personal Responder session and an active Responder + /// passkey with approval on). Department policy is the caller's check. + /// + Task IsAvailableAsync(string userId, UserSessionClientApplication requestingClient, CancellationToken cancellationToken = default); + + // ── Requester ───────────────────────────────────────────────────────────── + + /// Creates the request, replacing the user's pending one, and notifies their Responder. The number is returned once. + Task RequestAsync(MfaApprovalRequester requester, CancellationToken cancellationToken = default); + + /// The request, only for the requester that made it, with its state as it reads now. + Task GetForRequesterAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, + CancellationToken cancellationToken = default); + + Task CancelAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, + CancellationToken cancellationToken = default); + + /// + /// Uses an approved request once, for the requester that made it, under the account's current generation and while + /// the approving passkey and Responder session are still valid. The requester records the evidence. + /// + Task ConsumeAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, string userId, + long authenticationGeneration, CancellationToken cancellationToken = default); + + /// + /// Stops approval requests on one of the user's Responder installations, or all of them with a null + /// , which also turns approval off on every Responder passkey (plan section 6.5). + /// Pending requests end, and approvals from a stopped installation no longer count. Returns how many installations and + /// passkeys stopped. + /// + Task<(int Installations, int Passkeys)> DisableInstallationsAsync(string userId, string installationId, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default); + + /// Whether an approval's passkey and Responder session (its factor reference) still count. + Task IsApproverValidAsync(string userId, string factorReference, long authenticationGeneration, CancellationToken cancellationToken = default); + + // ── Approver (Responder) ────────────────────────────────────────────────── + + /// The user's pending request for an eligible Responder session to review, or null. + Task GetPendingForApproverAsync(PasskeyCaller approver, CancellationToken cancellationToken = default); + + /// Assertion options for the approver's approval-enabled Responder passkeys, bound to the request. + Task<(MfaApprovalOutcome Outcome, PasskeyCeremonyStart Ceremony)> BeginApprovalAsync(PasskeyCaller approver, string approvalRequestId, + CancellationToken cancellationToken = default); + + /// Checks the number, verifies the passkey assertion, and approves in one compare-and-set. + Task<(MfaApprovalResult Result, PasskeyOutcome Passkey)> ApproveAsync(PasskeyCaller approver, string approvalRequestId, string matchNumber, + string requestId, string credentialJson, CancellationToken cancellationToken = default); + + /// Denies the request; "not me" also ends the requester's sign-in and suspends approval requests for 15 minutes. + Task DenyAsync(PasskeyCaller approver, string approvalRequestId, MfaApprovalEndReason reason, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaCredentialStateService.cs b/Core/Resgrid.Model/Services/IMfaCredentialStateService.cs new file mode 100644 index 000000000..0abb7640a --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaCredentialStateService.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The revocation state of the credential behind a verification (passkey plan section 8.2). A passkey grant names the + /// passkey and its state version; an approval grant, the approving Responder passkey and session; a provider step-up + /// grant, the department's tested mapping and its version. Revoking or changing any of them voids the grant at its next + /// use. TOTP needs none of this: advancing the account's authentication generation revokes it. + /// + public interface IMfaCredentialStateService + { + /// + /// The credential a verification used, while it still counts for , + /// and ; null when it no longer does (or the method needs no credential). + /// + Task ResolveAsync(string userId, int departmentId, UserSessionClientApplication client, MfaEvidenceMethod method, + string factorReference, long authenticationGeneration, CancellationToken cancellationToken = default); + + /// Whether the credential a version 2 grant names still counts, at the state version the grant carries. + Task IsCurrentAsync(ProtectedDataGrant grant, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaEvidenceService.cs b/Core/Resgrid.Model/Services/IMfaEvidenceService.cs new file mode 100644 index 000000000..fc3aee353 --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaEvidenceService.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Server-side MFA evidence per session (passkey plan sections 5.3 and 6.2). Written only where a factor was actually + /// verified; read by guards that need a fresh first factor or a recent second factor. Recovery evidence is recorded + /// separately and never satisfies either. + /// + public interface IMfaEvidenceService + { + /// Records one verified factor for the session. Retention comes from TwoFactorConfig. + Task RecordAsync(string userId, string sessionKey, UserSessionClientApplication client, MfaEvidenceKind kind, + MfaEvidenceMethod method, MfaEvidencePurpose purpose, DateTime verifiedOnUtc, long authenticationGeneration, + int? departmentId = null, string factorReference = null, CancellationToken cancellationToken = default); + + /// The latest first-factor verification for this session under the user's current generation. + Task GetLatestFirstFactorAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default); + + /// True when a first factor was verified for this session within . + Task HasFreshFirstFactorAsync(string userId, string sessionKey, long currentGeneration, TimeSpan maxAge, + DateTime utcNow, CancellationToken cancellationToken = default); + + /// The latest second-factor verification for this session under the user's current generation. + Task GetLatestSecondFactorAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default); + + /// + /// The latest explicit step-up (not a sign-in) for this session under the user's current generation, for operations + /// whose rule is an explicit verification (chat export, plan section 7.6 row 8). + /// + Task GetLatestStepUpAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default); + + /// Retires every piece of evidence for the user (MFA turned off or replaced, factors recovered). + Task RevokeForUserAsync(string userId, CancellationToken cancellationToken = default); + + /// Retires the evidence one factor instance produced, such as a removed passkey (plan section 6.1 item 8). + Task RevokeForFactorAsync(string userId, string factorReference, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaLoginTransactionService.cs b/Core/Resgrid.Model/Services/IMfaLoginTransactionService.cs new file mode 100644 index 000000000..b4d2e54c8 --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaLoginTransactionService.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The method-neutral login MFA transaction (passkey plan sections 5.2, 7.2 and 7.5 rule 3). The first factor starts + /// it; any accepted second factor (TOTP, a passkey bound to this app, or a recovery code) completes it once; the + /// completion code is redeemed once at the token endpoint for the normal token response. The password or IdP token is + /// never resent or kept. Verifying the factor itself is the caller's job; this service owns the transaction state. + /// + public interface IMfaLoginTransactionService + { + bool IsEnabled { get; } + + /// Starts a transaction and returns its secret once, with the methods it accepts. + Task BeginAsync(MfaLoginTransactionRequest request, CancellationToken cancellationToken = default); + + /// + /// The transaction for a secret, if it is still pending for this client, unexpired, under its attempt limit, and + /// neither the account's generation nor the department's MFA policy has moved. + /// + Task OpenAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default); + + /// Whether the department accepts for this login now (plan section 7.6 rows 1-4). + Task IsMethodAcceptedAsync(MfaLoginTransaction transaction, MfaEvidenceMethod method, CancellationToken cancellationToken = default); + + Task RecordFailedAttemptAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default); + + /// Records the verified factor and issues the one-use completion code. + Task CompleteAsync(MfaLoginTransaction transaction, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, CancellationToken cancellationToken = default); + + /// + /// Starts a transaction that is already complete, for a login whose first factor needs no second factor (brokered + /// SSO for an account without MFA, plan section 7.7.2 step 5). It is redeemed at the token endpoint like any other, + /// so the session is created in one place, and it records no second factor. + /// + Task BeginCompletedAsync(MfaLoginTransactionRequest request, CancellationToken cancellationToken = default); + + /// + /// As , for an SSO sign-in whose own + /// round trip already satisfied MFA at the provider (plan section 7.8 flow 1): the completion records that method. + /// + Task BeginCompletedAsync(MfaLoginTransactionRequest request, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, CancellationToken cancellationToken = default); + + /// Ends a pending transaction that will issue nothing, because factor recovery takes over from it (plan section 5.4). + Task AbandonAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default); + + /// Redeems the completion code once, rechecking the account generation and department policy. + Task RedeemAsync(string secret, string completionCode, UserSessionClientApplication client, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IMfaPolicyService.cs b/Core/Resgrid.Model/Services/IMfaPolicyService.cs new file mode 100644 index 000000000..5c405867b --- /dev/null +++ b/Core/Resgrid.Model/Services/IMfaPolicyService.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The shared MFA policy decisions (passkey plan section 7.6): whether a department requires MFA on a path, and which + /// second factors a user may use there. Lookup failures are thrown, never read as "not required"; callers refuse. + /// + public interface IMfaPolicyService + { + /// + /// DepartmentSecurityPolicy.RequireMfa on the paths newly covered by this delivery (Web password login, the + /// API password grant, Web department entry): the department's flag, and only once the rollout gate is on. + /// + Task IsRequireMfaEnforcedAsync(int? departmentId, CancellationToken cancellationToken = default); + + /// The department's RequireMfa flag regardless of the rollout gate (the API SSO exchange has always enforced it). + Task DepartmentRequiresMfaAsync(int? departmentId, CancellationToken cancellationToken = default); + + /// The user's second-factor choice in a department for a scope (plan section 7.5 rule 5). + Task GetMethodChoiceAsync(string userId, bool totpEnrolled, int? departmentId, MfaMethodScope scope, bool passkeyEnrolled = false, + bool federatedEnrolled = false, bool approvalEnrolled = false, CancellationToken cancellationToken = default); + + /// + /// Whether evidence verified with is accepted for in the department + /// now (plan sections 7.6 and 10.1). A method the department has since switched off no longer counts, so the user + /// verifies again with an accepted one. TOTP is always accepted, without a policy read. + /// + Task IsMethodAcceptedAsync(int? departmentId, MfaMethodScope scope, MfaEvidenceMethod method, + CancellationToken cancellationToken = default); + + /// + /// Whether counts for in the department now: its method is + /// accepted, and evidence from unlocking a shared session serves protected data only where the department's + /// AcceptRecentUnlockMfaForAdp is on (plan section 12.5.3). + /// + Task IsEvidenceAcceptedAsync(int? departmentId, MfaMethodScope scope, MfaEvidence evidence, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IPasskeyService.cs b/Core/Resgrid.Model/Services/IPasskeyService.cs new file mode 100644 index 000000000..9f86af382 --- /dev/null +++ b/Core/Resgrid.Model/Services/IPasskeyService.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Passkey enrollment, inventory, revocation and step-up assertions (passkey plan sections 6.1 and 6.5). Every command + /// rechecks the rollout gate, the client's relying party and the caller's server-side evidence; a passkey is bound to + /// the client that registered it and is only ever verified for that client. + /// + public interface IPasskeyService + { + /// Whether a passkey could be registered from on this deployment now. + bool IsRegistrationAvailable(UserSessionClientApplication client); + + /// + /// Creation options for a new passkey bound to the caller's client. Requires a password or SSO verification and an + /// accepted second factor for this session within five minutes, TOTP enrolled and recovery codes remaining. + /// + Task BeginRegistrationAsync(PasskeyCaller caller, bool totpEnrolled, int recoveryCodesRemaining, + CancellationToken cancellationToken = default); + + /// Verifies the attestation, spends the request, stores the public credential and audits it. + Task CompleteRegistrationAsync(PasskeyCaller caller, string requestId, string credentialJson, + string displayName, CancellationToken cancellationToken = default); + + /// The user's own active passkeys in every client (plan section 6.1 item 6). + Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + + Task HasActiveForClientAsync(string userId, UserSessionClientApplication client, CancellationToken cancellationToken = default); + + Task CountActiveForUserAsync(string userId, CancellationToken cancellationToken = default); + + Task RenameAsync(PasskeyCaller caller, string userPasskeyId, string displayName, CancellationToken cancellationToken = default); + + /// + /// Revokes one of the caller's passkeys, in any client, after an accepted second factor within five minutes. Its + /// evidence and the user's pending challenges are retired with it, and sessions that signed in with it end. + /// + Task RevokeAsync(PasskeyCaller caller, string userPasskeyId, CancellationToken cancellationToken = default); + + Task RevokeAllForClientAsync(PasskeyCaller caller, UserSessionClientApplication client, + CancellationToken cancellationToken = default); + + /// Responder passkeys only: whether the credential may approve other apps' requests (plan section 7.9). + Task SetApprovalEnabledAsync(PasskeyCaller caller, string userPasskeyId, bool enabled, + CancellationToken cancellationToken = default); + + /// + /// Assertion options limited to the caller's passkeys for the calling client, bound to the caller's session or, for + /// , to its login transaction. + /// + Task BeginAssertionAsync(PasskeyCaller caller, AuthenticationChallengePurpose purpose, + CancellationToken cancellationToken = default); + + /// + /// Verifies an assertion against the passkey it names, which must be the caller's and bound to the calling client, + /// then spends the request and records the use. Recording evidence is the caller's job. + /// + Task CompleteAssertionAsync(PasskeyCaller caller, AuthenticationChallengePurpose purpose, string requestId, + string credentialJson, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedGrantContext.cs b/Core/Resgrid.Model/Services/IProtectedGrantContext.cs index 5f8e3d738..12f9a46c1 100644 --- a/Core/Resgrid.Model/Services/IProtectedGrantContext.cs +++ b/Core/Resgrid.Model/Services/IProtectedGrantContext.cs @@ -17,5 +17,11 @@ public interface IProtectedGrantContext /// True when no attended user is behind the call (worker, system principal, relay). bool IsWorkloadCaller { get; } + + /// + /// The validated session behind an attended call (passkey plan section 8.3), or null for a workload or a session + /// that is not tracked. A version 2 grant is refused whenever this is null. + /// + Security.ProtectedGrantSessionContext Session { get; } } } diff --git a/Core/Resgrid.Model/Services/IRelyingPartyRegistry.cs b/Core/Resgrid.Model/Services/IRelyingPartyRegistry.cs new file mode 100644 index 000000000..777f86819 --- /dev/null +++ b/Core/Resgrid.Model/Services/IRelyingPartyRegistry.cs @@ -0,0 +1,31 @@ +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// The per-client relying parties from PasskeyConfig.RelyingParties, validated once (workbook section 5). A client with + /// no valid entry has no passkeys; nothing ever falls back to another client's RP. + /// + public interface IRelyingPartyRegistry + { + PasskeyReadiness Readiness { get; } + + /// The client's relying party, or null when it has none or the configuration is not ready. + RelyingPartyDescriptor Get(UserSessionClientApplication client); + } + + /// + /// Effective rollout gates: a PasskeyConfig gate counts only when the relying-party configuration is ready, so a + /// half-configured deployment fails closed. + /// + public interface IPasskeyFeatureGates + { + bool RegistrationEnabled { get; } + bool LoginAcceptanceEnabled { get; } + bool AdpAcceptanceEnabled { get; } + bool EmitGrantV2 { get; } + bool SharedDeviceModeEnabled { get; } + bool ResponderApprovalEnabled { get; } + bool ProviderStepUpEnabled { get; } + } +} diff --git a/Core/Resgrid.Model/Services/ISearchServices.cs b/Core/Resgrid.Model/Services/ISearchServices.cs index 41dc72f9b..874369e2e 100644 --- a/Core/Resgrid.Model/Services/ISearchServices.cs +++ b/Core/Resgrid.Model/Services/ISearchServices.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,26 @@ public interface ISearchProjectionService Task ProjectDeploymentAsync(Invoicing.Deployment deployment, CancellationToken cancellationToken = default); Task ProjectCertificationTypeAsync(DepartmentCertificationType type, CancellationToken cancellationToken = default); + // Operations reference families (plan R3 Tier 2). + Task ProjectProtocolAsync(DispatchProtocol protocol, CancellationToken cancellationToken = default); + Task ProjectTrainingAsync(Training training, CancellationToken cancellationToken = default); + Task ProjectCalendarItemAsync(CalendarItem item, CancellationToken cancellationToken = default); + Task ProjectLogAsync(Log log, CancellationToken cancellationToken = default); + Task ProjectPoiAsync(Poi poi, CancellationToken cancellationToken = default); + Task ProjectShiftAsync(Shift shift, CancellationToken cancellationToken = default); + Task ProjectGroupAsync(DepartmentGroup group, CancellationToken cancellationToken = default); + Task ProjectOccupancyAsync(RmsOccupancy occupancy, CancellationToken cancellationToken = default); + + /// + /// Re-reads the entity and rewrites its projection, for writes that change a projected child or related row without + /// passing the entity itself: custom field values, role and group membership, member identification numbers, contact + /// notes. Supported for Call, Unit, Personnel, Contact and Occupancy; never throws. + /// + Task RefreshAsync(int departmentId, string entityType, string entityId, CancellationToken cancellationToken = default); + + /// A group was renamed or moved: re-project its members and its units, whose projections carry the group name. + Task RefreshGroupDependentsAsync(int departmentId, int departmentGroupId, CancellationToken cancellationToken = default); + Task RemoveAsync(int departmentId, string entityType, string entityId, CancellationToken cancellationToken = default); /// Builds the projection row without saving it (used by rebuilds and tests). Null when nothing safe can be indexed. @@ -52,6 +73,17 @@ public interface ISearchProjectionService Task BuildServiceContractAsync(Invoicing.ServiceContract contract); Task BuildDeploymentAsync(Invoicing.Deployment deployment); Task BuildCertificationTypeAsync(DepartmentCertificationType type); + Task BuildProtocolAsync(DispatchProtocol protocol); + Task BuildTrainingAsync(Training training); + /// Null for an occurrence of a recurring series: the series parent carries the event. + Task BuildCalendarItemAsync(CalendarItem item); + Task BuildLogAsync(Log log); + /// The POI's type; POIs carry their department only through it. + Task BuildPoiAsync(Poi poi, PoiType type); + Task BuildShiftAsync(Shift shift); + Task BuildGroupAsync(DepartmentGroup group); + /// Null for a removed or merged occupancy. + Task BuildOccupancyAsync(RmsOccupancy occupancy); /// Upserts a prebuilt row (rebuild path). Task UpsertAsync(SearchProjection projection, CancellationToken cancellationToken = default); @@ -90,6 +122,12 @@ public interface IGlobalSearchIndexer Task CountDocumentsAsync(int departmentId); + /// + /// The RowVersion each of these projections currently carries in the index, keyed by projection id; rows that are + /// not indexed are absent. The catch-up sweep uses it to skip rows its overlap window re-reads unchanged. + /// + Task> GetIndexedRowVersionsAsync(int departmentId, IEnumerable projections); + /// True when a local index exists for this process (after a pull or a write). bool IndexExists { get; } } @@ -112,6 +150,14 @@ public class GlobalSearchQuery public bool Prefix { get; set; } public int Skip { get; set; } public int Take { get; set; } = 50; + /// Only documents that occurred at or after this instant (UTC). + public DateTime? FromUtc { get; set; } + /// Only documents that occurred at or before this instant (UTC). + public DateTime? ToUtc { get; set; } + /// One of ; relevance when null. A query without text is always newest first. + public string Sort { get; set; } + /// Deepest hit (skip + take) this query may reach; 0 = SearchConfig.MaxResults. Capped by SearchConfig.MaxPageWindow. + public int MaxWindow { get; set; } } public class GlobalSearchHit @@ -157,6 +203,12 @@ public interface IGlobalSearchService public interface IUnifiedSearchService { Task SearchAsync(UnifiedSearchRequest request, SearchPrincipal principal, CancellationToken cancellationToken = default); + + /// + /// The entity families (, Record included) this caller may search right now: flag, + /// membership, view claims and module switches applied. Empty when search is unavailable to the caller. + /// + Task> GetSearchableEntityTypesAsync(SearchPrincipal principal, CancellationToken cancellationToken = default); } /// Searches the static system-functionality catalog for one caller. diff --git a/Core/Resgrid.Model/Services/ISecurityNoticeService.cs b/Core/Resgrid.Model/Services/ISecurityNoticeService.cs new file mode 100644 index 000000000..1fbf2874a --- /dev/null +++ b/Core/Resgrid.Model/Services/ISecurityNoticeService.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Security notices to the account holder (passkey plan section 6.4) through a durable user-level outbox, behind + /// TwoFactorConfig.SecurityNoticesEnabled (off). A notice is + /// queued after the change it reports has committed, delivered at once where possible, and retried by worker 13 until it + /// is sent or fails for good. Queueing and delivery never throw into the caller: a lost notice is logged and audited, + /// and never undoes the change. + /// + public interface ISecurityNoticeService + { + Task QueueAsync(SecurityNoticeRequest request, CancellationToken cancellationToken = default); + + /// As , unless the user already got this kind of notice within . + Task QueueOnceAsync(SecurityNoticeRequest request, TimeSpan within, CancellationToken cancellationToken = default); + + /// Sends due notices (retries), up to ; returns how many were sent. + Task DeliverDueAsync(int batchSize, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/ISharedSessionService.cs b/Core/Resgrid.Model/Services/ISharedSessionService.cs new file mode 100644 index 000000000..25ba012c3 --- /dev/null +++ b/Core/Resgrid.Model/Services/ISharedSessionService.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Durable transitions of a shared vehicle tablet or workstation session (passkey plan sections 5.5 and 12.5.3). Lock and + /// unlock are compare-and-set on the session itself; the factor that unlocks is verified by the caller, and this service + /// only commits the unlock it proved. + /// + public interface ISharedSessionService + { + /// The session's shared state and deadlines, under the department's current policy. + Task GetStatusAsync(UserSession session, CancellationToken cancellationToken = default); + + /// + /// Locks the session and advances its lock version, so every grant, challenge, approval and piece of evidence from + /// before it is void. Locking a locked session succeeds with the current version. + /// + Task LockAsync(UserSession session, SharedSessionRequestInfo request, CancellationToken cancellationToken = default); + + /// + /// Why the session cannot be unlocked by its operator now (normal sign-in is needed instead), or + /// . Checked before any factor is verified. + /// + Task CanUnlockAsync(UserSession session, CancellationToken cancellationToken = default); + + /// + /// Unlocks the session at after the caller verified + /// for its operator, and records that as SharedUnlock evidence. The first-factor time and the shift ceiling are + /// unchanged. + /// + Task UnlockAsync(UserSession session, long expectedLockVersion, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, SharedSessionRequestInfo request, CancellationToken cancellationToken = default); + + /// Audits a failed unlock verification. + Task RecordFailedUnlockAsync(UserSession session, string method, SharedSessionRequestInfo request, CancellationToken cancellationToken = default); + + /// Ends the session for End shift or Switch operator; the next operator signs in normally. + Task EndShiftAsync(UserSession session, bool switchOperator, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/ISsoBrokerService.cs b/Core/Resgrid.Model/Services/ISsoBrokerService.cs new file mode 100644 index 000000000..7b355caf2 --- /dev/null +++ b/Core/Resgrid.Model/Services/ISsoBrokerService.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Security; + +namespace Resgrid.Model.Services +{ + /// + /// Server-brokered SSO for every client (passkey plan section 7.7.2; workbook section 7.3): begin, the IdP callbacks + /// (OIDC and SAML), and one-time redemption with PKCE. It establishes the first factor only; login MFA follows through + /// the login transaction, and nothing here issues a token. + /// + public interface ISsoBrokerService + { + /// Whether the gate is on and the return-target registry is valid. + bool IsEnabled { get; } + + /// The OIDC redirect URI departments register with their IdP. + string OidcRedirectUri { get; } + + /// Whether a department's configuration can run brokered SSO (a SAML IdP needs its SSO URL). + bool SupportsBrokered(DepartmentSsoConfig config); + + /// The opaque, system-encrypted department token clients send instead of a department code. + string DepartmentTokenFor(Department department); + + /// Resolves the department from a department token, a department code, or a username's default department. + Task ResolveDepartmentAsync(string departmentToken, string departmentCode, string username, CancellationToken cancellationToken = default); + + Task BeginAsync(SsoBeginRequest request, CancellationToken cancellationToken = default); + + Task CompleteOidcCallbackAsync(string state, string code, string error, string clientIpAddress, + CancellationToken cancellationToken = default); + + /// True when a SAML RelayState belongs to a brokered transaction rather than the legacy relay. + bool IsBrokeredRelayState(string relayState); + + /// + /// Where a legacy (unbrokered) SAML sign-in starts: the department's IdP SSO URL with an AuthnRequest (signed when the + /// department has an SP key) whose RelayState is the app's own tagged value, so the response returns through the legacy + /// relay to that app. Nothing is stored; the exchange validates the response as it does any the relay carries. Null + /// when the configuration cannot start one (the same pieces a brokered SAML sign-in needs) or the key cannot be used. + /// asks the IdP to authenticate the member again, for a shared installation whose browser + /// may still hold the last operator's IdP session (plan section 12.5.2). + /// + string LegacySamlSignInUrl(DepartmentSsoConfig config, string departmentCode, string relayState, bool forceAuthn); + + Task CompleteSamlCallbackAsync(string relayState, string samlResponse, string clientIpAddress, + CancellationToken cancellationToken = default); + + /// + /// Redeems the one-time code with the client's PKCE verifier, once, for the client that began it. Only a transaction + /// for one of is redeemed (default: login and reauthentication, what Sso/Redeem + /// serves), so a code sent to the wrong endpoint is refused without being spent. + /// + Task RedeemAsync(string transactionId, string code, string codeVerifier, UserSessionClientApplication client, + CancellationToken cancellationToken = default, params SsoTransactionPurpose[] purposes); + + /// Records an id_token as used until it expires; false when it was already used (plan section 7.7.2 item 8). + Task TryRecordIdTokenUseAsync(string idToken, DateTime expiresOnUtc, CancellationToken cancellationToken = default); + } + + /// The deployment's registered SSO return targets, per client (workbook section 7.3). + public interface ISsoReturnTargetRegistry + { + bool IsReady { get; } + + System.Collections.Generic.IReadOnlyList Problems { get; } + + bool IsAllowed(UserSessionClientApplication client, string returnTarget); + } +} diff --git a/Core/Resgrid.Model/Services/IUserSessionService.cs b/Core/Resgrid.Model/Services/IUserSessionService.cs index d30fca7cb..2d12e88db 100644 --- a/Core/Resgrid.Model/Services/IUserSessionService.cs +++ b/Core/Resgrid.Model/Services/IUserSessionService.cs @@ -26,6 +26,23 @@ Task MoveSessionToDepartmentAsync(string userId, string sessionId, int dep Task RevokeOtherSessionsAsync(string userId, string currentSessionId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default); Task RevokeAllAsync(string actorUserId, string targetUserId, UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default); Task RevokeAllAfterCredentialChangeAsync(string actorUserId, string targetUserId, UserSessionRevocationReason reason, DateTime validAfterUtc, CancellationToken cancellationToken = default); + /// + /// Moves an unlocked shared session's idle deadline to now, because the operator did something (the client says so + /// with X-Resgrid-Operator-Activity; polling and sockets never do). Written at most once per + /// PasskeyConfig.SharedActivityWriteIntervalSeconds, and never for a session whose deadline already passed. + /// + Task RecordOperatorActivityAsync(UserSession session, CancellationToken cancellationToken = default); + + /// + /// Which of the given sessions can no longer be used: missing, ended, expired, locked, or a shared session past its + /// recorded idle deadline. For closing open SignalR connections in bulk (slice 16); a request still gets the full + /// validation, which also applies a stricter current department policy. + /// + Task> GetUnusableSessionIdsAsync(IReadOnlyCollection sessionIds, CancellationToken cancellationToken = default); + + /// Asks every SignalR host to close this session's open connections now; best effort. + Task CloseConnectionsAsync(string sessionId); + Task RevokeDepartmentSessionsAsync(string targetUserId, int departmentId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/SharedSessionRules.cs b/Core/Resgrid.Model/SharedSessionRules.cs new file mode 100644 index 000000000..a5d866a75 --- /dev/null +++ b/Core/Resgrid.Model/SharedSessionRules.cs @@ -0,0 +1,157 @@ +using System; +using Resgrid.Config; +using Resgrid.Model.Security; + +namespace Resgrid.Model +{ + /// The attended apps a department can require shared mode for (plan section 10.5). + [Flags] + public enum SharedModeApps + { + None = 0, + Unit = 1, + Command = 2, + Dispatch = 4 + } + + /// Why a session is shared. Kept on the session so its provenance is never re-derived from a label. + public enum SharedModeSource + { + None = 0, + + /// The installation asked for shared mode. A request to be stricter, so it needs no proof. + InstallationRequested = 1, + + /// The department's policy requires it for this app, or for any session that does not say which app it is. + DepartmentRequired = 2 + } + + public enum SharedSessionLockReason + { + /// The operator (or the app, on an OS lock or backgrounding) asked to lock. + Explicit = 1, + + /// The server's idle deadline passed. + Idle = 2 + } + + /// + /// Pure decisions for shared vehicle tablets and workstations (passkey plan sections 5.5, 10.5 and 12.5.3). The server + /// owns them: a client can ask for shared mode or report activity, but it cannot relax the department's policy, move a + /// deadline past the shift ceiling, or make anything from before a lock usable after it. + /// + public static class SharedSessionRules + { + public const int DefaultIdleLockMinutes = 5; + public const int DefaultShiftHours = 12; + + /// Session validation's failure code for a shared session that is locked (or whose idle deadline passed). + public const string LockedFailureCode = "shared_session_locked"; + + /// Session validation's failure code for a shared session past its shift ceiling. + public const string ExpiredFailureCode = "shared_session_expired"; + + /// Sign-in request header asking for a shared session (true). A label that can only tighten. + public const string InstallationHeader = "X-Resgrid-Shared-Installation"; + + /// Request header a shared-mode client sends on requests the operator caused (1). + public const string ActivityHeader = "X-Resgrid-Operator-Activity"; + + public static int MaxIdleLockMinutes => Math.Clamp(PasskeyConfig.SharedMaxIdleLockMinutes, 1, 15); + + public static int MaxShiftHours => Math.Clamp(PasskeyConfig.SharedMaxShiftHours, 1, 24); + + /// The department's idle lock, within 1 minute and the deployment's cap. No policy row means the default. + public static int IdleLockMinutes(DepartmentSecurityPolicy policy) => + Math.Clamp(policy?.SharedIdleLockMinutes ?? DefaultIdleLockMinutes, 1, MaxIdleLockMinutes); + + /// The department's shift ceiling, within 1 hour and the deployment's cap. + public static int ShiftHours(DepartmentSecurityPolicy policy) => + Math.Clamp(policy?.SharedShiftHours ?? DefaultShiftHours, 1, MaxShiftHours); + + /// + /// Whether the department requires shared mode for a session of . A session that does not + /// say which app it is (no or an unknown X-Resgrid-Client) is required whenever any app is, so omitting the + /// header never relaxes the requirement. Web, Responder, BigBoard and MCP sessions are not covered. + /// + public static bool IsRequiredFor(DepartmentSecurityPolicy policy, UserSessionClientApplication client) + { + var required = (SharedModeApps)(policy?.SharedModeRequiredApps ?? 0) & (SharedModeApps.Unit | SharedModeApps.Command | SharedModeApps.Dispatch); + if (required == SharedModeApps.None) + return false; + + return client switch + { + UserSessionClientApplication.Unit => required.HasFlag(SharedModeApps.Unit), + UserSessionClientApplication.Command => required.HasFlag(SharedModeApps.Command), + UserSessionClientApplication.Dispatch => required.HasFlag(SharedModeApps.Dispatch), + UserSessionClientApplication.Api or UserSessionClientApplication.UnknownLegacy => true, + _ => false + }; + } + + /// + /// Whether a session a sign-in creates is shared, and why (plan section 10.5). The department's requirement always + /// applies, even with the deployment gate off, because turning the gate off must not waive it; an installation's own + /// request is honored only while (the shared-device gate) is on. + /// + public static SharedModeSource SourceFor(DepartmentSecurityPolicy policy, UserSessionClientApplication client, bool requested, bool deviceModeEnabled) => + IsRequiredFor(policy, client) ? SharedModeSource.DepartmentRequired + : requested && deviceModeEnabled ? SharedModeSource.InstallationRequested + : SharedModeSource.None; + + /// True when a sign-in's asks for shared mode. + public static bool IsRequested(string headerValue) + { + var value = headerValue?.Trim(); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1" || + string.Equals(value, "shared", StringComparison.OrdinalIgnoreCase); + } + + /// The session's idle lock now: what sign-in recorded, or the department's current value if that is stricter. + public static int EffectiveIdleLockMinutes(UserSession session, DepartmentSecurityPolicy policy) => + Math.Min(Math.Clamp(session?.SharedIdleLockMinutes ?? DefaultIdleLockMinutes, 1, MaxIdleLockMinutes), IdleLockMinutes(policy)); + + /// When the session ends whatever the activity: its recorded ceiling, or sooner under a stricter current policy. + public static DateTime ShiftEndsOn(UserSession session, DepartmentSecurityPolicy policy) + { + var byPolicy = session.CreatedOn.AddHours(ShiftHours(policy)); + return session.ExpiresOn < byPolicy ? session.ExpiresOn : byPolicy; + } + + /// When an unlocked shared session locks unless the operator does something first. + public static DateTime IdleLocksOn(UserSession session, DepartmentSecurityPolicy policy) => + (session.LastOperatorActivityOn ?? session.CreatedOn).AddMinutes(EffectiveIdleLockMinutes(session, policy)); + + public static bool IdleLockDue(UserSession session, DepartmentSecurityPolicy policy, DateTime utcNow) => + session != null && session.SharedMode && !session.IsLocked && IdleLocksOn(session, policy) <= utcNow; + + /// + /// Whether evidence verified at can count for this session. On a shared session, + /// nothing counts while it is locked, and nothing verified before its last lock counts after it is unlocked. A missing + /// session (an untracked Web session) is not shared. + /// + public static bool EvidenceCounts(UserSession session, DateTime verifiedOnUtc) => + session == null || !session.SharedMode || + (!session.IsLocked && (session.LockedOnUtc == null || verifiedOnUtc > session.LockedOnUtc.Value)); + + /// The lock version grants, challenges and approvals bind to: the session's own when shared, otherwise none. + public static long? LockVersionOf(UserSession session) => session?.SharedMode == true ? session.LockVersion : null; + + /// + /// Whether a redeemed provider step-up unlocks this locked session (plan sections 7.8 and 12.5.3): begun for a shared + /// unlock of this very session and operator, in its department, under the department's tested mapping, at the current + /// generation, and after the session's last lock, so a round trip from before a lock or for anything else never unlocks it. + /// + public static bool FederatedUnlockMatches(SsoLoginTransaction transaction, UserSession session, string userId, int departmentId, + DepartmentSsoConfig testedConfig) => + transaction != null && session != null && !string.IsNullOrWhiteSpace(userId) && + transaction.DepartmentId == departmentId && FederatedMfaMapping.Satisfies(transaction, testedConfig) && + string.Equals(transaction.Operation, SsoLoginTransaction.SharedUnlockOperation, StringComparison.Ordinal) && + string.Equals(transaction.SessionId, session.UserSessionId, StringComparison.Ordinal) && + string.Equals(transaction.ExpectedUserId, userId, StringComparison.OrdinalIgnoreCase) && + string.Equals(transaction.UserId, userId, StringComparison.OrdinalIgnoreCase) && + transaction.AuthenticationGeneration == session.AuthenticationGeneration && + session.LockedOnUtc != null && transaction.CreatedOnUtc > session.LockedOnUtc.Value; + } +} diff --git a/Core/Resgrid.Model/SystemAuditTypes.cs b/Core/Resgrid.Model/SystemAuditTypes.cs index 39bf2c2fe..bf5291112 100644 --- a/Core/Resgrid.Model/SystemAuditTypes.cs +++ b/Core/Resgrid.Model/SystemAuditTypes.cs @@ -27,6 +27,27 @@ public enum SystemAuditTypes ExternalIdentityLinked = 22, ExternalIdentityUnlinked = 23, PasswordResetLinkSentByAdministrator = 24, - SessionHistoryPurged = 25 + SessionHistoryPurged = 25, + TwoFactorAuthenticatorReplaced = 26, + AccountReauthenticated = 27, + PasskeyRegistered = 28, + PasskeyRevoked = 29, + PasskeyRenamed = 30, + PasskeyApprovalChanged = 31, + FederatedMfaMappingTested = 32, + MfaApprovalRequested = 33, + MfaApprovalApproved = 34, + MfaApprovalDenied = 35, + FactorRecoveryStarted = 36, + FactorRecoveryCompleted = 37, + FactorRecoveryCanceled = 38, + SecurityNoticeFailed = 39, + SharedSessionStarted = 40, + SharedSessionLocked = 41, + SharedSessionUnlocked = 42, + SharedSessionEnded = 43, + MfaActivityReported = 44, + ApprovalInstallationsDisabled = 45, + FederatedMfaMappingChanged = 46 } } diff --git a/Core/Resgrid.Model/UserSession.cs b/Core/Resgrid.Model/UserSession.cs index 4e4e94f2c..85338f06a 100644 --- a/Core/Resgrid.Model/UserSession.cs +++ b/Core/Resgrid.Model/UserSession.cs @@ -47,6 +47,51 @@ public class UserSession : IEntity [MaxLength(128)] public string RevokedByUserId { get; set; } public int? RevocationReason { get; set; } + /// + /// The second factor this session's sign-in verified (MfaEvidenceMethod), kept apart from the first-factor + /// (passkey plan section 5.3). Null when sign-in verified none or predates it. + /// + public int? LoginMfaMethod { get; set; } + + /// The factor instance that sign-in used, such as passkey:{id}, so removing it can end this session. + [MaxLength(256)] public string LoginMfaFactorReference { get; set; } + + /// + /// A shared vehicle tablet or workstation session (passkey plan sections 5.5 and 12.5): it locks when idle, ends at the + /// shift ceiling (), and a locked session reaches only its status, unlock and end-shift endpoints. + /// Set once at sign-in; nothing turns it off. + /// + public bool SharedMode { get; set; } + + /// Why the session is shared (SharedModeSource): the installation asked, or the department requires it. + public int SharedModeSource { get; set; } + + /// The idle lock the department's policy set at sign-in. A stricter current policy still applies. + public int? SharedIdleLockMinutes { get; set; } + + /// + /// Advanced by every lock, never by an unlock. Grants, challenges and approvals are bound to the version they were + /// issued at, so nothing from before a lock is usable after it. + /// + public long LockVersion { get; set; } + + public bool IsLocked { get; set; } + + /// When the session last locked. Kept after unlock: evidence verified before it no longer counts. + public DateTime? LockedOnUtc { get; set; } + + /// Why it last locked (SharedSessionLockReason). + public int? LockReason { get; set; } + + /// + /// The last operator activity the client reported (server time). The idle deadline counts from here; polling and + /// socket traffic never move it. + /// + public DateTime? LastOperatorActivityOn { get; set; } + + /// A Responder installation that stopped taking approval requests (plan section 6.5); it no longer approves. + public DateTime? ApprovalsDisabledOnUtc { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/UserSessionRevocationReason.cs b/Core/Resgrid.Model/UserSessionRevocationReason.cs index d74b177d7..d603a822e 100644 --- a/Core/Resgrid.Model/UserSessionRevocationReason.cs +++ b/Core/Resgrid.Model/UserSessionRevocationReason.cs @@ -15,6 +15,9 @@ public enum UserSessionRevocationReason ConcurrentSessionLimit = 10, Expired = 11, LoggedOut = 12, - AccountDeactivated = 13 + AccountDeactivated = 13, + MfaChanged = 14, + ShiftEnded = 15, + OperatorSwitched = 16 } } diff --git a/Core/Resgrid.Search/LuceneGlobalSearchIndexer.cs b/Core/Resgrid.Search/LuceneGlobalSearchIndexer.cs index 29a45aa71..2d0d9778e 100644 --- a/Core/Resgrid.Search/LuceneGlobalSearchIndexer.cs +++ b/Core/Resgrid.Search/LuceneGlobalSearchIndexer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Lucene.Net.Index; @@ -83,6 +84,43 @@ public Task ExpungeDeletesAsync(CancellationToken cancellationToken = default) return SearchIndexPublishCoordinator.ExpungeAndPublishAsync(_host, _leases, cancellationToken); } + public Task> GetIndexedRowVersionsAsync(int departmentId, IEnumerable projections) + { + IDictionary versions = new Dictionary(StringComparer.Ordinal); + var rows = (projections ?? Array.Empty()) + .Where(p => p != null && p.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(p.SearchProjectionId) + && !string.IsNullOrWhiteSpace(p.EntityType) && !string.IsNullOrWhiteSpace(p.EntityId)) + .ToList(); + if (rows.Count == 0) + return Task.FromResult(versions); + + var manager = _host.GetSearcherManager(); + if (manager == null) + return Task.FromResult(versions); + + _host.MaybeRefresh(); + var searcher = manager.Acquire(); + try + { + foreach (var row in rows) + { + var key = SearchProjection.BuildKey(row.DepartmentId, row.EntityType, row.EntityId); + var hit = searcher.Search(new TermQuery(new Term(GlobalIndexFields.Key, key)), 1); + if (hit.TotalHits == 0) + continue; + var doc = searcher.Doc(hit.ScoreDocs[0].Doc); + if (long.TryParse(doc.Get(GlobalIndexFields.RowVersion), out var version)) + versions[row.SearchProjectionId] = version; + } + } + finally + { + manager.Release(searcher); + } + + return Task.FromResult(versions); + } + public Task CountDocumentsAsync(int departmentId) { var manager = _host.GetSearcherManager(); diff --git a/Core/Resgrid.Search/LuceneGlobalSearchService.cs b/Core/Resgrid.Search/LuceneGlobalSearchService.cs index d4867a2fc..09040a154 100644 --- a/Core/Resgrid.Search/LuceneGlobalSearchService.cs +++ b/Core/Resgrid.Search/LuceneGlobalSearchService.cs @@ -2,11 +2,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Lucene.Net.Analysis.TokenAttributes; using Lucene.Net.Index; -using Lucene.Net.QueryParsers.Classic; using Lucene.Net.Search; using Resgrid.Config; using Resgrid.Framework; @@ -81,7 +81,7 @@ public Task SearchAsync(int departmentId, GlobalSearchQuery var lucene = BuildQuery(departmentId, query); // Skip is clamped to the candidate ceiling before the addition: an unbounded offset would overflow the window // negative and IndexSearcher.Search rejects a non-positive hit count instead of returning an empty page. - var max = Math.Max(1, SearchConfig.MaxResults); + var max = MaxWindow(query); var take = query.Take <= 0 ? 50 : Math.Min(query.Take, max); var skip = Math.Min(Math.Max(0, query.Skip), max); var window = Math.Min(skip + take, max); @@ -90,12 +90,13 @@ public Task SearchAsync(int departmentId, GlobalSearchQuery try { var hasText = !string.IsNullOrWhiteSpace(query.Text); - var topDocs = hasText + var sort = SearchSortOrders.Normalize(query.Sort); + var topDocs = hasText && sort == SearchSortOrders.Relevance ? searcher.Search(lucene, window) - : searcher.Search(lucene, window, new Sort(new SortField(GlobalIndexFields.OccurredOnSort, SortFieldType.INT64, true))); + : searcher.Search(lucene, window, new Sort(new SortField(GlobalIndexFields.OccurredOnSort, SortFieldType.INT64, sort != SearchSortOrders.Oldest))); result.Total = topDocs.TotalHits; - result.Truncated = topDocs.TotalHits > SearchConfig.MaxResults; + result.Truncated = topDocs.TotalHits > max; foreach (var scoreDoc in topDocs.ScoreDocs.Skip(skip).Take(take)) { @@ -173,6 +174,15 @@ public Task GetHealthAsync() return Task.FromResult(health); } + /// The deepest hit a query may reach: SearchConfig.MaxResults, or the caller's larger window up to SearchConfig.MaxPageWindow. + public static int MaxWindow(GlobalSearchQuery query) + { + var max = Math.Max(1, SearchConfig.MaxResults); + if (query != null && query.MaxWindow > max) + max = Math.Max(max, Math.Min(query.MaxWindow, Math.Max(1, SearchConfig.MaxPageWindow))); + return max; + } + /// Visible for tests: the exact query the service runs. public static Query BuildQuery(int departmentId, GlobalSearchQuery request) { @@ -231,6 +241,13 @@ BooleanQuery ScopedTypes() if (!request.IncludeAdminOnly) query.Add(new TermQuery(new Term(GlobalIndexFields.IsAdminOnly, "1")), Occur.MUST_NOT); + if (request.FromUtc.HasValue || request.ToUtc.HasValue) + { + long? from = request.FromUtc.HasValue ? request.FromUtc.Value.Ticks : (long?)null; + long? to = request.ToUtc.HasValue ? request.ToUtc.Value.Ticks : (long?)null; + query.Add(NumericRangeQuery.NewInt64Range(GlobalIndexFields.OccurredOn, from, to, true, true), Occur.MUST); + } + if (!string.IsNullOrWhiteSpace(request.Text)) { var text = request.Text.Trim(); @@ -255,43 +272,39 @@ BooleanQuery ScopedTypes() } else { - using var analyzer = GlobalIndexFields.CreateQueryAnalyzer(); - var parser = new MultiFieldQueryParser(GlobalIndexFields.Version, TextFields, analyzer, TextBoosts) + // Every word, and every "quoted phrase" as a phrase, must occur in some text field (title, keywords, + // summary or the full text, which carries call notes). The clauses are built here from analyzed tokens + // rather than handed to a query parser, so no request syntax beyond the quotes reaches Lucene (plan R2.4). + var clauses = ParseClauses(text); + if (clauses.Count > 0) { - DefaultOperator = Operator.AND, - AllowLeadingWildcard = false - }; - Query parsed; - try - { - parsed = parser.Parse(QueryParserBase.Escape(text)); - } - catch (ParseException) - { - parsed = null; - } - if (parsed != null) - textQuery.Add(parsed, Occur.SHOULD); + var all = new BooleanQuery(); + foreach (var clause in clauses) + all.Add(TextClause(clause), Occur.MUST); + textQuery.Add(all, Occur.SHOULD); - // A final partial token still prefix-matches (the user is mid-word). - if (tokens.Count > 0) - { - var last = tokens[tokens.Count - 1]; - var lastPrefix = new BooleanQuery(); - foreach (var token in tokens.Take(tokens.Count - 1)) + // A final unquoted word still prefix-matches (the user is mid-word): titles and identifiers through + // their edge n-grams, the summary and full text through a prefix query once the stub is long enough. + var last = clauses[clauses.Count - 1]; + if (!last.Quoted && last.Tokens.Count == 1) { - lastPrefix.Add(new BooleanQuery + var stub = last.Tokens[0].Text; + var lastPrefix = new BooleanQuery(); + foreach (var clause in clauses.Take(clauses.Count - 1)) + lastPrefix.Add(TextClause(clause), Occur.MUST); + var stubQuery = new BooleanQuery + { + { new TermQuery(new Term(GlobalIndexFields.TitlePrefix, stub)) { Boost = 2f }, Occur.SHOULD }, + { new TermQuery(new Term(GlobalIndexFields.KeywordsPrefix, stub)) { Boost = 3f }, Occur.SHOULD } + }; + if (stub.Length >= MinTextPrefixLength) { - { new TermQuery(new Term(GlobalIndexFields.TitlePrefix, token)), Occur.SHOULD }, - { new TermQuery(new Term(GlobalIndexFields.KeywordsPrefix, token)), Occur.SHOULD } - }, Occur.MUST); + stubQuery.Add(new PrefixQuery(new Term(GlobalIndexFields.Summary, stub)) { Boost = 1.5f }, Occur.SHOULD); + stubQuery.Add(new PrefixQuery(new Term(GlobalIndexFields.SearchText, stub)), Occur.SHOULD); + } + lastPrefix.Add(stubQuery, Occur.MUST); + textQuery.Add(lastPrefix, Occur.SHOULD); } - lastPrefix.Add(new BooleanQuery - { - { new TermQuery(new Term(GlobalIndexFields.TitlePrefix, last)) { Boost = 2f }, Occur.SHOULD }, - { new TermQuery(new Term(GlobalIndexFields.KeywordsPrefix, last)) { Boost = 3f }, Occur.SHOULD } - }, Occur.MUST); - textQuery.Add(lastPrefix, Occur.SHOULD); } } @@ -302,6 +315,104 @@ BooleanQuery ScopedTypes() return query; } + /// Shortest mid-word stub expanded against the summary and full text; shorter stubs match titles and identifiers only. + public const int MinTextPrefixLength = 3; + + private const int MaxClauses = 12; + + private static readonly Regex QueryParts = new Regex("\"([^\"]*)\"|([^\\s\"]+)", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + /// One analyzed token and its position inside its clause (stop words leave gaps). + public sealed class ClauseToken + { + public string Text { get; set; } + public int Position { get; set; } + } + + /// A word or a phrase of the user's query, already analyzed. + public sealed class QueryClause + { + public bool Quoted { get; set; } + public List Tokens { get; set; } = new List(); + } + + /// + /// Splits the user text into clauses: each "quoted phrase" is one clause, each other word is one clause (a word the + /// analyzer splits, such as 2026-000123, stays one phrase clause). Stop words vanish; an unmatched quote is ignored. + /// + public static List ParseClauses(string text) + { + var clauses = new List(); + if (string.IsNullOrWhiteSpace(text)) + return clauses; + + foreach (Match match in QueryParts.Matches(text)) + { + if (clauses.Count >= MaxClauses) + break; + var quoted = match.Groups[1].Success; + var tokens = AnalyzeWithPositions(quoted ? match.Groups[1].Value : match.Groups[2].Value); + if (tokens.Count > 0) + clauses.Add(new QueryClause { Quoted = quoted, Tokens = tokens }); + } + + return clauses; + } + + private static Query TextClause(QueryClause clause) + { + var any = new BooleanQuery(); + foreach (var field in TextFields) + { + Query fieldQuery; + if (clause.Tokens.Count == 1) + { + fieldQuery = new TermQuery(new Term(field, clause.Tokens[0].Text)); + } + else + { + var phrase = new PhraseQuery(); + foreach (var token in clause.Tokens) + phrase.Add(new Term(field, token.Text), token.Position); + fieldQuery = phrase; + } + fieldQuery.Boost = TextBoosts[field]; + any.Add(fieldQuery, Occur.SHOULD); + } + return any; + } + + private static List AnalyzeWithPositions(string text) + { + var tokens = new List(); + if (string.IsNullOrWhiteSpace(text)) + return tokens; + + using var analyzer = GlobalIndexFields.CreateQueryAnalyzer(); + using var stream = analyzer.GetTokenStream(GlobalIndexFields.SearchText, new StringReader(text)); + var term = stream.AddAttribute(); + var increment = stream.AddAttribute(); + stream.Reset(); + var position = -1; + while (stream.IncrementToken() && tokens.Count < 16) + { + position += Math.Max(1, increment.PositionIncrement); + var value = term.ToString(); + if (value.Length > 0) + tokens.Add(new ClauseToken { Text = value, Position = position }); + } + stream.End(); + + // Positions relative to the clause: a leading stop word must not shift the whole phrase. + if (tokens.Count > 0) + { + var first = tokens[0].Position; + foreach (var token in tokens) + token.Position -= first; + } + return tokens; + } + /// Standard-analyzer tokens of the user text (lower-cased, stop words removed), never more than 12. public static List Tokenize(string text) { diff --git a/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs b/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs index dbf2388f4..11975003c 100644 --- a/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs +++ b/Core/Resgrid.Services/AdminAssist/SecurityImpactService.cs @@ -115,7 +115,11 @@ private async Task ReadPolicyAsync(int departmentId, C return Copy(row ?? new DepartmentSecurityPolicy { DepartmentId = departmentId }); } private static DepartmentSecurityPolicy Copy(DepartmentSecurityPolicy p) => new() { DepartmentId = p.DepartmentId, RequireMfa = p.RequireMfa, RequireSso = p.RequireSso, - SessionTimeoutMinutes = p.SessionTimeoutMinutes, MaxConcurrentSessions = p.MaxConcurrentSessions, PasswordExpirationDays = p.PasswordExpirationDays, MinPasswordLength = p.MinPasswordLength }; + SessionTimeoutMinutes = p.SessionTimeoutMinutes, MaxConcurrentSessions = p.MaxConcurrentSessions, PasswordExpirationDays = p.PasswordExpirationDays, MinPasswordLength = p.MinPasswordLength, + // The second-factor method switches (passkey plan section 10.1) travel with every preview so a proposal never reads them as off. + AllowPasskeysForLoginMfa = p.AllowPasskeysForLoginMfa, AllowPasskeysForAdp = p.AllowPasskeysForAdp, AllowFederatedMfaForLoginMfa = p.AllowFederatedMfaForLoginMfa, + AllowFederatedMfaForAdp = p.AllowFederatedMfaForAdp, AllowResponderApproval = p.AllowResponderApproval, AcceptRecentLoginMfaForAdp = p.AcceptRecentLoginMfaForAdp, + AcceptRecentUnlockMfaForAdp = p.AcceptRecentUnlockMfaForAdp, MfaPolicyVersion = p.MfaPolicyVersion }; private static decimal Value(DepartmentSecurityPolicy p, string field) => field switch { "SessionTimeoutMinutes" => p.SessionTimeoutMinutes, "MaxConcurrentSessions" => p.MaxConcurrentSessions, "PasswordExpirationDays" => p.PasswordExpirationDays, "MinPasswordLength" => p.MinPasswordLength, _ => throw new ArgumentException() }; private static void Apply(DepartmentSecurityPolicy p, string field, ConfigurationImpactRequest request) diff --git a/Core/Resgrid.Services/AdpReleaseService.cs b/Core/Resgrid.Services/AdpReleaseService.cs index 38bd90a9d..8ead44e10 100644 --- a/Core/Resgrid.Services/AdpReleaseService.cs +++ b/Core/Resgrid.Services/AdpReleaseService.cs @@ -18,14 +18,17 @@ public sealed class AdpReleaseService(IAdpAccessStore store, IAdpAuditRepository IAdpReleaseReceiptService receipts, IProtectedDataBrokerClient broker, ICallsService calls, IAuthorizationService authorization, IUserProfileService profiles, IDepartmentsService departments, IPermissionsService permissions, IDepartmentGroupsService groups, IPersonnelRolesService roles, - IPhoneNumberProcesserProvider phoneNumbers) : IAdpReleaseService + IPhoneNumberProcesserProvider phoneNumbers, IProtectedGrantContext grantContext = null, + IMfaCredentialStateService credentialStates = null) : IAdpReleaseService { public async Task EnrollPinAsync(int departmentId, string userId, string grantToken, string pin, CancellationToken cancellationToken = default) { if (pin == null || !Regex.IsMatch(pin, "^[0-9]{6,12}$")) return false; var policy = await protection.GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); if (policy == null || grants.ValidateGrant(grantToken, departmentId, policy.PolicyEpoch, ProtectedDataGrantScopes.Read, - out var grant) != ProtectedDataGrantValidationOutcome.Valid || grant.UserId != userId || grant.StepUpExempt || + out var grant) != ProtectedDataGrantValidationOutcome.Valid || grant.UserId != userId || + await ProtectedGrantBinding.CheckAsync(grant, userId, ProtectedGrantBinding.SessionFor(grantContext, userId), policy.StepUpWindowMinutes, + credentialStates, cancellationToken) != Resgrid.Model.Security.ProtectedGrantBindingOutcome.Bound || grant.StepUpExempt || grant.MfaAtUtc < DateTime.UtcNow.AddMinutes(-5) || grant.MfaAtUtc > DateTime.UtcNow.AddSeconds(30)) return false; var key = PinKey(departmentId, userId); var previous = await store.GetAsync(key, cancellationToken); diff --git a/Core/Resgrid.Services/AdpStepUpService.cs b/Core/Resgrid.Services/AdpStepUpService.cs new file mode 100644 index 000000000..f2d557298 --- /dev/null +++ b/Core/Resgrid.Services/AdpStepUpService.cs @@ -0,0 +1,454 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class AdpStepUpService : IAdpStepUpService + { + private static readonly string[] Scopes = { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }; + + private readonly IProtectedDataGrantService _grants; + private readonly IDepartmentDataProtectionService _protection; + private readonly IMfaPolicyService _policy; + private readonly IMfaEvidenceService _evidence; + private readonly IPasskeyService _passkeys; + private readonly IMfaApprovalService _approvals; + private readonly ISsoBrokerService _broker; + private readonly IDepartmentSsoService _departmentSso; + private readonly IMfaCredentialStateService _credentials; + private readonly IMfaActivityService _activity; + private readonly IAdpAuditRepository _audit; + private readonly IPasskeyFeatureGates _gates; + private readonly TimeProvider _time; + + public AdpStepUpService(IProtectedDataGrantService grants, IDepartmentDataProtectionService protection, IMfaPolicyService policy, + IMfaEvidenceService evidence, IPasskeyService passkeys, IMfaApprovalService approvals, ISsoBrokerService broker, + IDepartmentSsoService departmentSso, IMfaCredentialStateService credentials, IMfaActivityService activity, IAdpAuditRepository audit, + IPasskeyFeatureGates gates, TimeProvider time) + { + _grants = grants; + _protection = protection; + _policy = policy; + _evidence = evidence; + _passkeys = passkeys; + _approvals = approvals; + _broker = broker; + _departmentSso = departmentSso; + _credentials = credentials; + _activity = activity; + _audit = audit; + _gates = gates; + _time = time; + } + + private DateTime Now => _time.GetUtcNow().UtcDateTime; + + /// A passkey, approval or provider step-up grant is version 2, bound to a tracked session. + private bool CanIssueVersionTwo(AdpStepUpCaller caller) => + _gates.EmitGrantV2 && !string.IsNullOrWhiteSpace(caller?.Session?.SessionId); + + public async Task GetMethodChoiceAsync(AdpStepUpCaller caller, bool totpEnrolled, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + var versionTwo = CanIssueVersionTwo(caller); + var passkey = versionTwo && await _passkeys.HasActiveForClientAsync(caller.UserId, caller.Client, cancellationToken); + var approval = versionTwo && await _approvals.IsAvailableAsync(caller.UserId, caller.Client, cancellationToken); + var federated = versionTwo && await _departmentSso.IsFederatedMfaAvailableAsync(caller.DepartmentId, caller.UserId, cancellationToken); + return await _policy.GetMethodChoiceAsync(caller.UserId, totpEnrolled, caller.DepartmentId, MfaMethodScope.Adp, passkey, federated, approval, + cancellationToken); + } + + // ── Passkey ─────────────────────────────────────────────────────────────────── + + public async Task BeginPasskeyAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + if (!CanIssueVersionTwo(caller)) + return PasskeyCeremonyStart.Of(caller.Session == null ? PasskeyOutcome.SessionRequired : PasskeyOutcome.Unavailable); + if (!await _policy.IsMethodAcceptedAsync(caller.DepartmentId, MfaMethodScope.Adp, MfaEvidenceMethod.Passkey, cancellationToken)) + return PasskeyCeremonyStart.Of(PasskeyOutcome.Unavailable); + + return await _passkeys.BeginAssertionAsync(caller.ToPasskeyCaller(), AuthenticationChallengePurpose.AdpStepUp, cancellationToken); + } + + public async Task CompletePasskeyAsync(AdpStepUpCaller caller, string requestId, string credentialJson, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + var refusal = await RefuseBeforeVerifyingAsync(caller, MfaEvidenceMethod.Passkey, cancellationToken); + if (refusal != null) + return refusal; + + var assertion = await _passkeys.CompleteAssertionAsync(caller.ToPasskeyCaller(), AuthenticationChallengePurpose.AdpStepUp, requestId, + credentialJson, cancellationToken); + if (!assertion.Succeeded) + { + if (assertion.Outcome is PasskeyOutcome.VerificationFailed or PasskeyOutcome.NotRegisteredForClient) + await RecordDeniedAsync(caller, MfaEvidenceMethod.Passkey, cancellationToken); + await AuditVerifyAsync(caller, false); + return AdpGrantIssue.Of(assertion.Outcome == PasskeyOutcome.ServiceUnavailable ? AdpGrantOutcome.ServiceUnavailable : AdpGrantOutcome.VerificationFailed, + PasskeyOutcomes.ErrorCode(assertion.Outcome)); + } + + await AuditVerifyAsync(caller, true); + return await IssueAsync(caller, MfaEvidenceMethod.Passkey, assertion.VerifiedOnUtc, UserPasskey.FactorReferenceFor(assertion.Passkey.UserPasskeyId), + cancellationToken); + } + + // ── TOTP ────────────────────────────────────────────────────────────────────── + + public Task IssueForTotpAsync(AdpStepUpCaller caller, DateTime verifiedOnUtc, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + return IssueAsync(caller, MfaEvidenceMethod.Totp, verifiedOnUtc, null, cancellationToken); + } + + // ── Responder approval ──────────────────────────────────────────────────────── + + public async Task RequestApprovalAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + if (caller.Session == null) + return MfaApprovalStart.Of(MfaApprovalOutcome.SessionRequired); + if (!CanIssueVersionTwo(caller) || + !await _policy.IsMethodAcceptedAsync(caller.DepartmentId, MfaMethodScope.Adp, MfaEvidenceMethod.PasskeyApproval, cancellationToken)) + return MfaApprovalStart.Of(MfaApprovalOutcome.Unavailable); + + return await _approvals.RequestAsync(new MfaApprovalRequester + { + UserId = caller.UserId, + Kind = MfaApprovalRequesterKind.Session, + RequesterId = caller.Session.SessionId, + ClientApplication = caller.Client, + AuthenticationGeneration = caller.Session.AuthenticationGeneration, + DepartmentId = caller.DepartmentId, + Purpose = MfaApprovalPurpose.Adp, + SharedMode = caller.Session.SharedMode, + LockVersion = caller.Session.SessionLockVersion, + IpAddress = caller.IpAddress, + UserName = caller.UserName, + AuditSystem = caller.AuditSystem + }, cancellationToken); + } + + public async Task CompleteApprovalAsync(AdpStepUpCaller caller, string approvalRequestId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + var refusal = await RefuseBeforeVerifyingAsync(caller, MfaEvidenceMethod.PasskeyApproval, cancellationToken); + if (refusal != null) + return refusal; + + // The request must be this session's, for this department's protected data, at the lock version it was made at. + var found = await _approvals.GetForRequesterAsync(approvalRequestId, MfaApprovalRequesterKind.Session, caller.Session.SessionId, cancellationToken); + if (found.Succeeded && (found.Request.RequestPurpose != MfaApprovalPurpose.Adp || found.Request.DepartmentId != caller.DepartmentId || + found.Request.LockVersion != caller.Session.SessionLockVersion)) + return AdpGrantIssue.Of(AdpGrantOutcome.ApprovalUnavailable); + + var consumed = await _approvals.ConsumeAsync(approvalRequestId, MfaApprovalRequesterKind.Session, caller.Session.SessionId, caller.UserId, + caller.Session.AuthenticationGeneration, cancellationToken); + if (!consumed.Succeeded) + return consumed.Outcome switch + { + MfaApprovalOutcome.Pending => AdpGrantIssue.Of(AdpGrantOutcome.ApprovalPending), + MfaApprovalOutcome.ServiceUnavailable => AdpGrantIssue.Of(AdpGrantOutcome.ServiceUnavailable), + _ => AdpGrantIssue.Of(AdpGrantOutcome.ApprovalUnavailable, MfaApprovalOutcomes.ErrorCode(consumed.Outcome)) + }; + + var approval = consumed.Request; + await AuditVerifyAsync(caller, true); + return await IssueAsync(caller, MfaEvidenceMethod.PasskeyApproval, approval.DecidedOnUtc ?? Now, + MfaApprovalRequest.FactorReferenceFor(approval.ApproverPasskeyId, approval.ApproverSessionId), cancellationToken); + } + + // ── Provider step-up ────────────────────────────────────────────────────────── + + public async Task CompleteFederatedAsync(AdpStepUpCaller caller, string ssoTransactionId, string code, string codeVerifier, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + var refusal = await RefuseBeforeVerifyingAsync(caller, MfaEvidenceMethod.Federated, cancellationToken); + if (refusal != null) + return refusal; + + var redeemed = await _broker.RedeemAsync(ssoTransactionId, code, codeVerifier, caller.Client, cancellationToken, SsoTransactionPurpose.AdpStepUp); + var transaction = redeemed.Transaction; + var config = redeemed.Succeeded ? await _departmentSso.GetTestedFederatedMfaConfigAsync(caller.DepartmentId, cancellationToken) : null; + if (!redeemed.Succeeded || transaction.DepartmentId != caller.DepartmentId || !FederatedMfaMapping.Satisfies(transaction, config) || + !string.Equals(transaction.SessionId, caller.Session.SessionId, StringComparison.Ordinal) || + // Begun before this shared session's last lock: nothing from before a lock counts after it (plan section 12.5.3). + (caller.Session.SessionLockedOnUtc != null && transaction.CreatedOnUtc <= caller.Session.SessionLockedOnUtc.Value) || + !string.Equals(transaction.ExpectedUserId, caller.UserId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(transaction.UserId, caller.UserId, StringComparison.OrdinalIgnoreCase) || + transaction.AuthenticationGeneration != caller.Session.AuthenticationGeneration) + { + if (redeemed.Succeeded) + await RecordDeniedAsync(caller, MfaEvidenceMethod.Federated, cancellationToken); + await AuditVerifyAsync(caller, false); + return redeemed.Outcome == SsoBrokerOutcome.ServiceUnavailable + ? AdpGrantIssue.Of(AdpGrantOutcome.ServiceUnavailable) + : AdpGrantIssue.Of(AdpGrantOutcome.VerificationFailed, redeemed.Succeeded ? "federated_mfa_not_satisfied" : SsoBrokerOutcomes.ErrorCode(redeemed.Outcome)); + } + + await AuditVerifyAsync(caller, true); + return await IssueAsync(caller, MfaEvidenceMethod.Federated, transaction.AuthenticatedOnUtc ?? Now, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion), cancellationToken); + } + + // ── Exemption ───────────────────────────────────────────────────────────────── + + public async Task IssueExemptAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + + // The exemption answer and the epoch the grant is stamped with come from one policy snapshot, so a revocation + // between two reads cannot mint a grant carrying the epoch that revoked it. + var decision = await _protection.GetStepUpDecisionForClientAsync(caller.DepartmentId, caller.Client); + if (decision.StepUpRequired) + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + if (!_grants.CanIssueGrants) + return AdpGrantIssue.Of(AdpGrantOutcome.NotConfigured); + + var window = ProtectedGrantBinding.EffectiveWindowMinutes(decision.StepUpWindowMinutes); + var request = ProtectedGrantIssueRequests.ForSession(new ProtectedDataGrantIssueRequest + { + UserId = caller.UserId, + DepartmentId = caller.DepartmentId, + SessionId = caller.Session?.SessionId ?? caller.LegacySessionId, + ClientApp = (int)caller.Client, + PolicyEpoch = decision.PolicyEpoch, + WindowMinutes = window, + Scopes = Scopes, + MfaAtUtc = Now, + StepUpExempt = true + }, caller.Session, ProtectedDataGrantMfaMethods.None, _gates.EmitGrantV2); + if (request.Version == 2) + request.NotAfterUtc = caller.Session.SessionExpiresOnUtc; + + return await SignAsync(caller, request, window, "step-up-exempt", ProtectedDataGrantMfaMethods.None, decision.PolicyEpoch); + } + + // ── Shared orchestration ────────────────────────────────────────────────────── + + /// + /// What stops a passkey, approval or provider step-up before any factor is checked: no signing material, no tracked + /// session, version 2 not yet emitted, or a method the department or deployment does not accept for protected data. + /// + private async Task RefuseBeforeVerifyingAsync(AdpStepUpCaller caller, MfaEvidenceMethod method, CancellationToken cancellationToken) + { + if (!_grants.CanIssueGrants) + return AdpGrantIssue.Of(AdpGrantOutcome.NotConfigured); + if (caller.Session == null || string.IsNullOrWhiteSpace(caller.Session.SessionId)) + return AdpGrantIssue.Of(AdpGrantOutcome.SessionRequired); + if (!CanIssueVersionTwo(caller) || !await _policy.IsMethodAcceptedAsync(caller.DepartmentId, MfaMethodScope.Adp, method, cancellationToken)) + return AdpGrantIssue.Of(AdpGrantOutcome.MethodNotAllowed); + return null; + } + + /// + /// Issues a grant for a verification that has just succeeded (plan sections 8.1 and 9.2): the method must still be + /// accepted and its credential current; the verification is recorded as AdpStepUp evidence for this department + /// (it happened, whether or not a grant can be signed); the expiry runs from the verification, never from issuance, + /// and never past the session's end. + /// + // ── Recent sign-in or unlock evidence (plan section 9.1) ───────────────────────────────────────────────────────── + + /// + /// A grant from this session's own recent second factor, without a new prompt (plan section 9.1): the latest real verification + /// for this session, account generation and client, when the department accepts its method for protected data now and + /// accepts reusing it, and its credential still counts. Sign-in evidence needs AcceptRecentLoginMfaForAdp; shared + /// unlock evidence needs AcceptRecentUnlockMfaForAdp at the session's current lock; protected-data evidence counts only + /// in the department it was for. The grant expires from the original verification, never from now. + /// + public async Task IssueFromRecentEvidenceAsync(AdpStepUpCaller caller, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(caller); + if (!_grants.CanIssueGrants) + return AdpGrantIssue.Of(AdpGrantOutcome.NotConfigured); + if (caller.Session == null || string.IsNullOrWhiteSpace(caller.EvidenceKey)) + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + + var evidence = await _evidence.GetLatestSecondFactorAsync(caller.UserId, caller.EvidenceKey, caller.Session.AuthenticationGeneration, + cancellationToken); + if (evidence == null || evidence.Kind != (int)MfaEvidenceKind.SecondFactor || evidence.ClientApplication != (int)caller.Client || + evidence.AuthenticationGeneration != caller.Session.AuthenticationGeneration) + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + + var reusable = (MfaEvidencePurpose)evidence.Purpose switch + { + MfaEvidencePurpose.Login => (await _departmentSso.GetSecurityPolicyForDepartmentAsync(caller.DepartmentId, cancellationToken) + ?? new DepartmentSecurityPolicy()).AcceptRecentLoginMfaForAdp, + // The unlock switch and the lock are the policy service's and the evidence service's to decide. + MfaEvidencePurpose.SharedUnlock => true, + MfaEvidencePurpose.AdpStepUp => evidence.DepartmentId == caller.DepartmentId, + _ => false + }; + if (!reusable || !await _policy.IsEvidenceAcceptedAsync(caller.DepartmentId, MfaMethodScope.Adp, evidence, cancellationToken)) + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + + return await IssueAsync(caller, (MfaEvidenceMethod)evidence.Method, evidence.VerifiedOnUtc, evidence.FactorReference, cancellationToken, + reused: true); + } + + private async Task IssueAsync(AdpStepUpCaller caller, MfaEvidenceMethod method, DateTime verifiedOnUtc, string factorReference, + CancellationToken cancellationToken, bool reused = false) + { + var versionTwo = CanIssueVersionTwo(caller); + var grantMethod = GrantMethodFor(method); + if (grantMethod == null) + return AdpGrantIssue.Of(AdpGrantOutcome.MethodNotAllowed); + if (method != MfaEvidenceMethod.Totp) + { + if (caller.Session == null) + return AdpGrantIssue.Of(AdpGrantOutcome.SessionRequired); + if (!versionTwo || !await _policy.IsMethodAcceptedAsync(caller.DepartmentId, MfaMethodScope.Adp, method, cancellationToken)) + return AdpGrantIssue.Of(AdpGrantOutcome.MethodNotAllowed); + } + + // The credential the verification used must still count, at the state version the grant will carry. + MfaCredentialSnapshot credential = null; + if (method != MfaEvidenceMethod.Totp) + { + credential = await _credentials.ResolveAsync(caller.UserId, caller.DepartmentId, caller.Client, method, factorReference, + caller.Session.AuthenticationGeneration, cancellationToken); + if (credential == null) + return AdpGrantIssue.Of(AdpGrantOutcome.CredentialRevoked); + } + + verifiedOnUtc = DateTime.SpecifyKind(verifiedOnUtc, DateTimeKind.Utc); + // A reused verification is already this session's evidence; only a new one is recorded. + if (!reused) + await RecordEvidenceAsync(caller, method, verifiedOnUtc, factorReference, cancellationToken); + + if (!_grants.CanIssueGrants) + return AdpGrantIssue.Of(AdpGrantOutcome.NotConfigured); + + var policy = await _protection.GetPolicyByDepartmentIdAsync(caller.DepartmentId); + var window = ProtectedGrantBinding.EffectiveWindowMinutes(policy?.StepUpWindowMinutes ?? 0); + var now = Now; + + // Expiry: the earliest of verification plus the window and the session's end. Nothing already expired is issued, + // and nothing verified in the future beyond the allowed skew is believed. + var skew = TimeSpan.FromSeconds(Math.Max(0, DataProtectionConfig.GrantClockSkewSeconds)); + var expiry = verifiedOnUtc.AddMinutes(window); + if (versionTwo && caller.Session?.SessionExpiresOnUtc is DateTime sessionEnd && sessionEnd < expiry) + expiry = sessionEnd; + if (verifiedOnUtc > now.Add(skew) || expiry <= now) + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + + var request = ProtectedGrantIssueRequests.ForSession(new ProtectedDataGrantIssueRequest + { + UserId = caller.UserId, + DepartmentId = caller.DepartmentId, + SessionId = caller.Session?.SessionId ?? caller.LegacySessionId, + ClientApp = (int)caller.Client, + PolicyEpoch = policy?.PolicyEpoch ?? 0, + WindowMinutes = window, + Scopes = Scopes, + MfaAtUtc = verifiedOnUtc + }, caller.Session, grantMethod, versionTwo); + + if (request.Version == 2) + { + request.MfaCredentialId = credential?.CredentialId; + request.MfaStateVersion = credential?.StateVersion; + request.NotAfterUtc = caller.Session.SessionExpiresOnUtc; + } + + return await SignAsync(caller, request, window, reused ? "mfa-reused" : "mfa-verified", grantMethod, policy?.PolicyEpoch ?? 0); + } + + private async Task SignAsync(AdpStepUpCaller caller, ProtectedDataGrantIssueRequest request, int window, string auditOutcome, + string grantMethod, long policyEpoch) + { + ProtectedDataGrantIssueResult issued; + try + { + issued = _grants.IssueGrant(request); + } + catch (ArgumentException) + { + // The verification or the session ran out between the checks and signing. + return AdpGrantIssue.Of(AdpGrantOutcome.StepUpRequired); + } + catch (InvalidOperationException ex) + { + Logging.LogException(ex, "A Protected Data Grant could not be signed."); + return AdpGrantIssue.Of(AdpGrantOutcome.NotConfigured); + } + + await _audit.AppendAsync(new AdpAuditEvent + { + DepartmentId = caller.DepartmentId, + Layer = "identity", + Operation = "grant-issued", + Outcome = auditOutcome, + ActorId = caller.UserId, + CorrelationId = issued.GrantId, + ResourceId = grantMethod, + PolicyEpoch = policyEpoch + }); + + return new AdpGrantIssue + { + Outcome = AdpGrantOutcome.Issued, + GrantId = issued.GrantId, + Token = issued.Token, + ExpiresOnUtc = issued.ExpiresOnUtc, + WindowMinutes = window + }; + } + + private static string GrantMethodFor(MfaEvidenceMethod method) => method switch + { + MfaEvidenceMethod.Totp => ProtectedDataGrantMfaMethods.Totp, + MfaEvidenceMethod.Passkey => ProtectedDataGrantMfaMethods.Passkey, + MfaEvidenceMethod.PasskeyApproval => ProtectedDataGrantMfaMethods.PasskeyApproval, + MfaEvidenceMethod.Federated => ProtectedDataGrantMfaMethods.Federated, + _ => null + }; + + /// + /// Records the ADP step-up as evidence on the caller's session (purpose AdpStepUp, plan section 5.3), for later + /// reuse under section 9.1. The grant is this call's product, so a failure here is logged, not returned. + /// + private async Task RecordEvidenceAsync(AdpStepUpCaller caller, MfaEvidenceMethod method, DateTime verifiedOnUtc, string factorReference, + CancellationToken cancellationToken) + { + var sessionKey = caller.EvidenceKey; + if (sessionKey == null) + return; + + try + { + await _evidence.RecordAsync(caller.UserId, sessionKey, caller.Client, MfaEvidenceKind.SecondFactor, method, MfaEvidencePurpose.AdpStepUp, + verifiedOnUtc, caller.Session?.AuthenticationGeneration ?? caller.AccountAuthenticationGeneration, caller.DepartmentId, factorReference, + cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Failed to record ADP step-up evidence."); + } + } + + private Task RecordDeniedAsync(AdpStepUpCaller caller, MfaEvidenceMethod method, CancellationToken cancellationToken) => + _activity.RecordAsync(new MfaActivityEntry + { + UserId = caller.UserId, Method = method, Purpose = MfaEvidencePurpose.AdpStepUp, Successful = false, ClientApplication = caller.Client, + SharedMode = caller.Session?.SharedMode == true, DepartmentId = caller.DepartmentId, SessionId = caller.Session?.SessionId + }, cancellationToken); + + private Task AuditVerifyAsync(AdpStepUpCaller caller, bool verified) => + _audit.AppendAsync(new AdpAuditEvent + { + DepartmentId = caller.DepartmentId, Layer = "identity", Operation = "mfa-verify", Outcome = verified ? "verified" : "denied", ActorId = caller.UserId + }); + } +} diff --git a/Core/Resgrid.Services/AmbientProtectedGrantContext.cs b/Core/Resgrid.Services/AmbientProtectedGrantContext.cs index b10325e49..0bd5a5eb9 100644 --- a/Core/Resgrid.Services/AmbientProtectedGrantContext.cs +++ b/Core/Resgrid.Services/AmbientProtectedGrantContext.cs @@ -15,16 +15,20 @@ public sealed class WorkloadProtectedGrantContext : IProtectedGrantContext public string UserId => null; public bool IsWorkloadCaller => true; + + public Model.Security.ProtectedGrantSessionContext Session => null; } /// A fixed grant context for tests and one-off tool runs. public sealed class FixedProtectedGrantContext : IProtectedGrantContext { - public FixedProtectedGrantContext(string grantToken, bool isWorkloadCaller, string userId = null) + public FixedProtectedGrantContext(string grantToken, bool isWorkloadCaller, string userId = null, + Model.Security.ProtectedGrantSessionContext session = null) { GrantToken = grantToken; IsWorkloadCaller = isWorkloadCaller; UserId = userId; + Session = session; } public static FixedProtectedGrantContext Workload { get; } = new FixedProtectedGrantContext(null, true); @@ -32,5 +36,6 @@ public FixedProtectedGrantContext(string grantToken, bool isWorkloadCaller, stri public string GrantToken { get; } public string UserId { get; } public bool IsWorkloadCaller { get; } + public Model.Security.ProtectedGrantSessionContext Session { get; } } } diff --git a/Core/Resgrid.Services/ApprovalApprovers.cs b/Core/Resgrid.Services/ApprovalApprovers.cs new file mode 100644 index 000000000..e98e1102d --- /dev/null +++ b/Core/Resgrid.Services/ApprovalApprovers.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; + +namespace Resgrid.Services +{ + /// + /// Who may approve, and whether a past approval still counts (passkey plan section 7.9). An approver is a personal + /// Responder session of the same user under the current generation (never a shared one), with an active Responder + /// passkey that has approval on. An approval stops counting as soon as its passkey is revoked or has approval turned + /// off, or its Responder session ends, however that happened, because every read checks both. + /// + public static class ApprovalApprovers + { + public static bool IsEligibleSession(UserSession session, string userId, long authenticationGeneration, DateTime utcNow) => + session != null && string.Equals(session.UserId, userId, StringComparison.OrdinalIgnoreCase) && + session.ClientApplication == (int)UserSessionClientApplication.Responder && !session.SharedMode && session.ApprovalsDisabledOnUtc == null && + session.State == (int)UserSessionState.Active && session.RevokedOn == null && session.ExpiresOn > utcNow && + session.AuthenticationGeneration == authenticationGeneration; + + public static bool IsApprovingPasskey(UserPasskey passkey, string userId) => + passkey != null && passkey.IsActive && passkey.ApprovalEnabled && + passkey.ClientApplication == (int)UserSessionClientApplication.Responder && + string.Equals(passkey.UserId, userId, StringComparison.OrdinalIgnoreCase); + + /// Whether the passkey and Responder session an approval's factor reference names still count. + public static async Task IsValidAsync(IUserPasskeyRepository passkeys, IUserSessionsRepository sessions, string userId, + string factorReference, long authenticationGeneration, DateTime utcNow, CancellationToken cancellationToken = default) + { + if (!MfaApprovalRequest.TryParseFactorReference(factorReference, out var passkeyId, out var sessionId)) + return false; + + if (!IsApprovingPasskey(await passkeys.GetAsync(passkeyId, cancellationToken), userId)) + return false; + + return IsEligibleSession(await sessions.GetByIdAsync(sessionId), userId, authenticationGeneration, utcNow); + } + } +} diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index 5c568aec9..b8ea8a7c7 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -76,6 +76,8 @@ public string GetAuditLogTypeString(AuditLogTypes logType) { case AuditLogTypes.DepartmentConfigurationChanged: return "Department Configuration Changed"; + case AuditLogTypes.DepartmentSecurityPolicyChanged: + return "Security Policy Changed"; case AuditLogTypes.AdminAssistReviewChanged: return "Admin Assist Review Changed"; case AuditLogTypes.DepartmentSettingsChanged: @@ -254,6 +256,446 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "Moderation Evidence Downloaded"; case AuditLogTypes.UserReactivated: return "User Reactivated"; + // These types used to fall through to "Unknown (...)". The same English names are the + // AuditLogType* entries in Security.en.resx; AuditServiceTypeNameTests keeps the two in step. + case AuditLogTypes.SubscriptionUpdated: + return "Subscription Updated"; + case AuditLogTypes.SubscriptionCreated: + return "Subscription Created"; + case AuditLogTypes.SubscriptionCancelled: + return "Subscription Cancelled"; + case AuditLogTypes.SubscriptionBillingInfoUpdated: + return "Subscription Billing Info Updated"; + case AuditLogTypes.CallReactivated: + return "Call Reactivated"; + case AuditLogTypes.UserAccountDeleted: + return "User Account Deleted"; + case AuditLogTypes.AddonSubscriptionModified: + return "Add-on Subscription Modified"; + case AuditLogTypes.DeleteStaticShift: + return "Static Shift Deleted"; + case AuditLogTypes.UpdateStaticShift: + return "Static Shift Updated"; + case AuditLogTypes.CustomStatusAdded: + return "Custom Status Added"; + case AuditLogTypes.CustomStatusRemoved: + return "Custom Status Removed"; + case AuditLogTypes.CustomStatusUpdated: + return "Custom Status Updated"; + case AuditLogTypes.CustomStatusDetailUpdated: + return "Custom Status Detail Updated"; + case AuditLogTypes.CallTypeAdded: + return "Call Type Added"; + case AuditLogTypes.CallTypeEdited: + return "Call Type Edited"; + case AuditLogTypes.CallTypeRemoved: + return "Call Type Removed"; + case AuditLogTypes.CallPriorityAdded: + return "Call Priority Added"; + case AuditLogTypes.CallPriorityEdited: + return "Call Priority Edited"; + case AuditLogTypes.CallPriorityRemoved: + return "Call Priority Removed"; + case AuditLogTypes.UnitTypeAdded: + return "Unit Type Added"; + case AuditLogTypes.UnitTypeEdited: + return "Unit Type Edited"; + case AuditLogTypes.UnitTypeRemoved: + return "Unit Type Removed"; + case AuditLogTypes.CertificationTypeAdded: + return "Certification Type Added"; + case AuditLogTypes.CertificationTypeEdited: + return "Certification Type Edited"; + case AuditLogTypes.CertificationTypeRemoved: + return "Certification Type Removed"; + case AuditLogTypes.DocumentCategoryAdded: + return "Document Category Added"; + case AuditLogTypes.DocumentCategoryEdited: + return "Document Category Edited"; + case AuditLogTypes.DocumentCategoryRemoved: + return "Document Category Removed"; + case AuditLogTypes.DocumentAdded: + return "Document Added"; + case AuditLogTypes.DocumentEdited: + return "Document Edited"; + case AuditLogTypes.DocumentRemoved: + return "Document Removed"; + case AuditLogTypes.NoteCategoryAdded: + return "Note Category Added"; + case AuditLogTypes.NoteCategoryEdited: + return "Note Category Edited"; + case AuditLogTypes.NoteCategoryRemoved: + return "Note Category Removed"; + case AuditLogTypes.NoteAdded: + return "Note Added"; + case AuditLogTypes.NoteEdited: + return "Note Edited"; + case AuditLogTypes.NoteRemoved: + return "Note Removed"; + case AuditLogTypes.ContactAdded: + return "Contact Added"; + case AuditLogTypes.ContactEdited: + return "Contact Edited"; + case AuditLogTypes.ContactRemoved: + return "Contact Removed"; + case AuditLogTypes.ContactCategoryAdded: + return "Contact Category Added"; + case AuditLogTypes.ContactCategoryEdited: + return "Contact Category Edited"; + case AuditLogTypes.ContactCategoryRemoved: + return "Contact Category Removed"; + case AuditLogTypes.ContactNoteTypeAdded: + return "Contact Note Type Added"; + case AuditLogTypes.ContactNoteTypeEdited: + return "Contact Note Type Edited"; + case AuditLogTypes.ContactNoteTypeRemoved: + return "Contact Note Type Removed"; + case AuditLogTypes.RouteCreated: + return "Route Created"; + case AuditLogTypes.RouteUpdated: + return "Route Updated"; + case AuditLogTypes.RouteDeleted: + return "Route Deleted"; + case AuditLogTypes.RouteStarted: + return "Route Started"; + case AuditLogTypes.RouteCompleted: + return "Route Completed"; + case AuditLogTypes.RouteCancelled: + return "Route Cancelled"; + case AuditLogTypes.RoutePaused: + return "Route Paused"; + case AuditLogTypes.RouteResumed: + return "Route Resumed"; + case AuditLogTypes.RouteStopCheckedIn: + return "Route Stop Checked In"; + case AuditLogTypes.RouteStopCheckedOut: + return "Route Stop Checked Out"; + case AuditLogTypes.RouteStopSkipped: + return "Route Stop Skipped"; + case AuditLogTypes.RouteDeviationDetected: + return "Route Deviation Detected"; + case AuditLogTypes.RouteDeviationAcknowledged: + return "Route Deviation Acknowledged"; + case AuditLogTypes.CheckInTimerConfigCreated: + return "Check-In Timer Configuration Created"; + case AuditLogTypes.CheckInTimerConfigUpdated: + return "Check-In Timer Configuration Updated"; + case AuditLogTypes.CheckInTimerConfigDeleted: + return "Check-In Timer Configuration Deleted"; + case AuditLogTypes.CheckInTimerOverrideCreated: + return "Check-In Timer Override Created"; + case AuditLogTypes.CheckInTimerOverrideUpdated: + return "Check-In Timer Override Updated"; + case AuditLogTypes.CheckInTimerOverrideDeleted: + return "Check-In Timer Override Deleted"; + case AuditLogTypes.CheckInPerformed: + return "Check-In Performed"; + case AuditLogTypes.CheckInTimerEnabledOnCall: + return "Check-In Timer Enabled on Call"; + case AuditLogTypes.CheckInTimerDisabledOnCall: + return "Check-In Timer Disabled on Call"; + case AuditLogTypes.LogCreated: + return "Log Created"; + case AuditLogTypes.LogDeleted: + return "Log Deleted"; + case AuditLogTypes.CommunicationTestCreated: + return "Communication Test Created"; + case AuditLogTypes.CommunicationTestUpdated: + return "Communication Test Updated"; + case AuditLogTypes.CommunicationTestDeleted: + return "Communication Test Deleted"; + case AuditLogTypes.CommunicationTestRunStarted: + return "Communication Test Run Started"; + case AuditLogTypes.WeatherAlertSourceCreated: + return "Weather Alert Source Created"; + case AuditLogTypes.WeatherAlertSourceUpdated: + return "Weather Alert Source Updated"; + case AuditLogTypes.WeatherAlertSourceDeleted: + return "Weather Alert Source Deleted"; + case AuditLogTypes.WeatherAlertSourceEnabled: + return "Weather Alert Source Enabled"; + case AuditLogTypes.WeatherAlertSourceDisabled: + return "Weather Alert Source Disabled"; + case AuditLogTypes.WeatherAlertZoneCreated: + return "Weather Alert Zone Created"; + case AuditLogTypes.WeatherAlertZoneUpdated: + return "Weather Alert Zone Updated"; + case AuditLogTypes.WeatherAlertZoneDeleted: + return "Weather Alert Zone Deleted"; + case AuditLogTypes.WeatherAlertZoneEnabled: + return "Weather Alert Zone Enabled"; + case AuditLogTypes.WeatherAlertZoneDisabled: + return "Weather Alert Zone Disabled"; + case AuditLogTypes.WeatherAlertSettingsChanged: + return "Weather Alert Settings Changed"; + case AuditLogTypes.FeatureFlagChanged: + return "Feature Flag Changed"; + case AuditLogTypes.FeatureFlagOverrideChanged: + return "Feature Flag Override Changed"; + case AuditLogTypes.UserAuthenticationSessionsRevoked: + return "User Sign-In Sessions Revoked"; + case AuditLogTypes.DataProtectionStepUpExemptionsChanged: + return "Data Protection Step-Up Exemptions Changed"; + case AuditLogTypes.ContactPreplanAdded: + return "Contact Pre-Plan Added"; + case AuditLogTypes.ContactPreplanUpdated: + return "Contact Pre-Plan Updated"; + case AuditLogTypes.ContactPreplanRemoved: + return "Contact Pre-Plan Removed"; + case AuditLogTypes.ContactAttachmentAdded: + return "Contact Attachment Added"; + case AuditLogTypes.ContactAttachmentRemoved: + return "Contact Attachment Removed"; + case AuditLogTypes.ChecklistDefinitionAdded: + return "Checklist Definition Added"; + case AuditLogTypes.ChecklistDefinitionUpdated: + return "Checklist Definition Updated"; + case AuditLogTypes.ChecklistDefinitionPublished: + return "Checklist Definition Published"; + case AuditLogTypes.ChecklistDefinitionRetired: + return "Checklist Definition Retired"; + case AuditLogTypes.ChecklistDefinitionRemoved: + return "Checklist Definition Removed"; + case AuditLogTypes.ChecklistCompletionStarted: + return "Checklist Completion Started"; + case AuditLogTypes.ChecklistProgressSaved: + return "Checklist Progress Saved"; + case AuditLogTypes.ChecklistCompletionSubmitted: + return "Checklist Completion Submitted"; + case AuditLogTypes.ChecklistWitnessAttested: + return "Checklist Witness Attested"; + case AuditLogTypes.ChecklistFileAdded: + return "Checklist File Added"; + case AuditLogTypes.ChecklistFileRemoved: + return "Checklist File Removed"; + case AuditLogTypes.ChecklistScheduleAdded: + return "Checklist Schedule Added"; + case AuditLogTypes.ChecklistScheduleUpdated: + return "Checklist Schedule Updated"; + case AuditLogTypes.ChecklistOccurrenceMissed: + return "Checklist Occurrence Missed"; + case AuditLogTypes.ChecklistOccurrenceSkipped: + return "Checklist Occurrence Skipped"; + case AuditLogTypes.ChecklistReminderSettingsUpdated: + return "Checklist Reminder Settings Updated"; + case AuditLogTypes.WorkOrderChanged: + return "Work Order Changed"; + case AuditLogTypes.InventoryChanged: + return "Inventory Changed"; + case AuditLogTypes.BillingProfileChanged: + return "Billing Profile Changed"; + case AuditLogTypes.RateCardChanged: + return "Rate Card Changed"; + case AuditLogTypes.InvoiceCreated: + return "Invoice Created"; + case AuditLogTypes.InvoiceUpdated: + return "Invoice Updated"; + case AuditLogTypes.InvoiceSent: + return "Invoice Sent"; + case AuditLogTypes.InvoiceVoided: + return "Invoice Voided"; + case AuditLogTypes.InvoicePaymentRecorded: + return "Invoice Payment Recorded"; + case AuditLogTypes.DepartmentBillingIdentityChanged: + return "Department Billing Identity Changed"; + case AuditLogTypes.PaymentConnectionConnected: + return "Payment Connection Connected"; + case AuditLogTypes.PaymentConnectionDisconnected: + return "Payment Connection Disconnected"; + case AuditLogTypes.PaymentConnectionRevoked: + return "Payment Connection Revoked"; + case AuditLogTypes.PaymentConnectionActionRequired: + return "Payment Connection Action Required"; + case AuditLogTypes.InvoicePaymentRequestCreated: + return "Invoice Payment Request Created"; + case AuditLogTypes.InvoicePaymentRefunded: + return "Invoice Payment Refunded"; + case AuditLogTypes.InvoicePaymentDisputed: + return "Invoice Payment Disputed"; + case AuditLogTypes.PaymentWebhookRejected: + return "Payment Webhook Rejected"; + case AuditLogTypes.InvoicePaymentRequestFailed: + return "Invoice Payment Request Failed"; + case AuditLogTypes.InvoicePaymentRequestExpired: + return "Invoice Payment Request Expired"; + case AuditLogTypes.CertificationAdded: + return "Certification Added"; + case AuditLogTypes.CertificationUpdated: + return "Certification Updated"; + case AuditLogTypes.CertificationRemoved: + return "Certification Removed"; + case AuditLogTypes.CertificationStatusChanged: + return "Certification Status Changed"; + case AuditLogTypes.CertificationVerified: + return "Certification Verified"; + case AuditLogTypes.CertificationCreditAdded: + return "Certification Credit Added"; + case AuditLogTypes.CertificationCreditRemoved: + return "Certification Credit Removed"; + case AuditLogTypes.RoleCertificationRequirementChanged: + return "Role Certification Requirement Changed"; + case AuditLogTypes.DepartmentCertificationSettingsChanged: + return "Department Certification Settings Changed"; + case AuditLogTypes.RoleMemberAdded: + return "Role Member Added"; + case AuditLogTypes.RoleMemberRemoved: + return "Role Member Removed"; + case AuditLogTypes.RoleMemberRemovedByCertification: + return "Role Member Removed by Certification"; + case AuditLogTypes.UnitCertificationAdded: + return "Unit Certification Added"; + case AuditLogTypes.UnitCertificationUpdated: + return "Unit Certification Updated"; + case AuditLogTypes.UnitCertificationRemoved: + return "Unit Certification Removed"; + case AuditLogTypes.UnitCertificationStatusChanged: + return "Unit Certification Status Changed"; + case AuditLogTypes.DeploymentCreated: + return "Deployment Created"; + case AuditLogTypes.DeploymentUpdated: + return "Deployment Updated"; + case AuditLogTypes.DeploymentStatusChanged: + return "Deployment Status Changed"; + case AuditLogTypes.DeploymentRosterChanged: + return "Deployment Roster Changed"; + case AuditLogTypes.DeploymentEquipmentChanged: + return "Deployment Equipment Changed"; + case AuditLogTypes.DeploymentAttachmentAdded: + return "Deployment Attachment Added"; + case AuditLogTypes.DeploymentAttachmentRemoved: + return "Deployment Attachment Removed"; + case AuditLogTypes.TimeReportCreated: + return "Time Report Created"; + case AuditLogTypes.TimeReportUpdated: + return "Time Report Updated"; + case AuditLogTypes.TimeReportSubmitted: + return "Time Report Submitted"; + case AuditLogTypes.TimeReportApproved: + return "Time Report Approved"; + case AuditLogTypes.TimeReportVoided: + return "Time Report Voided"; + case AuditLogTypes.DeploymentExpenseAdded: + return "Deployment Expense Added"; + case AuditLogTypes.DeploymentExpenseUpdated: + return "Deployment Expense Updated"; + case AuditLogTypes.DeploymentExpenseRemoved: + return "Deployment Expense Removed"; + case AuditLogTypes.RateScheduleCreated: + return "Rate Schedule Created"; + case AuditLogTypes.RateScheduleUpdated: + return "Rate Schedule Updated"; + case AuditLogTypes.RateScheduleDeleted: + return "Rate Schedule Deleted"; + case AuditLogTypes.RateScheduleEntryChanged: + return "Rate Schedule Entry Changed"; + case AuditLogTypes.RatePremiumChanged: + return "Rate Premium Changed"; + case AuditLogTypes.ServiceContractCreated: + return "Service Contract Created"; + case AuditLogTypes.ServiceContractUpdated: + return "Service Contract Updated"; + case AuditLogTypes.ServiceContractStatusChanged: + return "Service Contract Status Changed"; + case AuditLogTypes.ServiceContractDeleted: + return "Service Contract Deleted"; + case AuditLogTypes.ComplianceDocumentAdded: + return "Compliance Document Added"; + case AuditLogTypes.ComplianceDocumentUpdated: + return "Compliance Document Updated"; + case AuditLogTypes.ComplianceDocumentRemoved: + return "Compliance Document Removed"; + case AuditLogTypes.BidCreated: + return "Bid Created"; + case AuditLogTypes.BidUpdated: + return "Bid Updated"; + case AuditLogTypes.BidSent: + return "Bid Sent"; + case AuditLogTypes.BidAccepted: + return "Bid Accepted"; + case AuditLogTypes.BidDeclined: + return "Bid Declined"; + case AuditLogTypes.BidWithdrawn: + return "Bid Withdrawn"; + case AuditLogTypes.BidExpired: + return "Bid Expired"; + case AuditLogTypes.BidConverted: + return "Bid Converted"; + case AuditLogTypes.BidDeleted: + return "Bid Deleted"; + case AuditLogTypes.TimeReportBilled: + return "Time Report Billed"; + case AuditLogTypes.DeploymentInvoiceGenerated: + return "Deployment Invoice Generated"; + case AuditLogTypes.CalOesMarsAgencyProfileChanged: + return "Cal OES MARS Agency Profile Changed"; + case AuditLogTypes.CalOesMarsResourceProfileChanged: + return "Cal OES MARS Resource Profile Changed"; + case AuditLogTypes.CalOesMarsRateProfileChanged: + return "Cal OES MARS Rate Profile Changed"; + case AuditLogTypes.CalOesMarsRateDraftBuilt: + return "Cal OES MARS Rate Draft Built"; + case AuditLogTypes.CalOesMarsRateReviewed: + return "Cal OES MARS Rate Reviewed"; + case AuditLogTypes.CalOesMarsAgreementChanged: + return "Cal OES MARS Agreement Changed"; + case AuditLogTypes.CalOesMarsAgreementObserved: + return "Cal OES MARS Agreement Observed"; + case AuditLogTypes.CalOesMarsWorkItemPrepared: + return "Cal OES MARS Work Item Prepared"; + case AuditLogTypes.CalOesMarsWorkItemValidated: + return "Cal OES MARS Work Item Validated"; + case AuditLogTypes.CalOesMarsReimbursementCalculated: + return "Cal OES MARS Reimbursement Calculated"; + case AuditLogTypes.CalOesMarsWorkItemOpenedForHandoff: + return "Cal OES MARS Work Item Opened for Handoff"; + case AuditLogTypes.CalOesMarsExternalStatusObserved: + return "Cal OES MARS External Status Observed"; + case AuditLogTypes.CalOesMarsInvoiceApproved: + return "Cal OES MARS Invoice Approved"; + case AuditLogTypes.CalOesMarsInvoiceRejected: + return "Cal OES MARS Invoice Rejected"; + case AuditLogTypes.CalOesMarsPaymentReconciled: + return "Cal OES MARS Payment Reconciled"; + case AuditLogTypes.CalOesMarsWorkItemDeleted: + return "Cal OES MARS Work Item Deleted"; + case AuditLogTypes.WorkforceEmployerProfileChanged: + return "Workforce Employer Profile Changed"; + case AuditLogTypes.WorkforceEstablishmentChanged: + return "Workforce Establishment Changed"; + case AuditLogTypes.WorkforceEmploymentChanged: + return "Workforce Employment Changed"; + case AuditLogTypes.WorkforceCompensationChanged: + return "Workforce Compensation Changed"; + case AuditLogTypes.WorkforceAnnualPayFactImported: + return "Workforce Annual Pay Data Imported"; + case AuditLogTypes.PayDataDemographicChanged: + return "Pay Data Demographics Changed"; + case AuditLogTypes.PayDataReportCreated: + return "Pay Data Report Created"; + case AuditLogTypes.PayDataReportValidated: + return "Pay Data Report Validated"; + case AuditLogTypes.PayDataReportFrozen: + return "Pay Data Report Frozen"; + case AuditLogTypes.PayDataReportExported: + return "Pay Data Report Exported"; + case AuditLogTypes.PayDataReportMarkedCertified: + return "Pay Data Report Marked Certified"; + case AuditLogTypes.PayDataReportCorrected: + return "Pay Data Report Corrected"; + case AuditLogTypes.ResourceCostProfileChanged: + return "Resource Cost Profile Changed"; + case AuditLogTypes.ResourceUsageChanged: + return "Resource Usage Changed"; + case AuditLogTypes.FieldCostRunCreated: + return "Field Cost Run Created"; + case AuditLogTypes.FieldCostRunFrozen: + return "Field Cost Run Frozen"; + case AuditLogTypes.AdminAssistDiagnosticAccess: + return "Admin Assist Diagnostic Access"; + case AuditLogTypes.AiDispatchSettingsUpdated: + return "AI Dispatch Settings Updated"; + case AuditLogTypes.AdminAssistPlanAccess: + return "Admin Assist Plan Access"; } return $"Unknown ({logType})"; diff --git a/Core/Resgrid.Services/AuthenticationChallengeService.cs b/Core/Resgrid.Services/AuthenticationChallengeService.cs new file mode 100644 index 000000000..04c661fde --- /dev/null +++ b/Core/Resgrid.Services/AuthenticationChallengeService.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class AuthenticationChallengeService : IAuthenticationChallengeService + { + private readonly IAuthenticationChallengeRepository _challenges; + private readonly TimeProvider _time; + + public AuthenticationChallengeService(IAuthenticationChallengeRepository challenges, TimeProvider time) + { + _challenges = challenges; + _time = time; + } + + public async Task CreateAsync(AuthenticationChallengeBinding binding, string rpId, string optionsJson, + CancellationToken cancellationToken = default) + { + if (binding == null || string.IsNullOrWhiteSpace(binding.UserId) || string.IsNullOrWhiteSpace(binding.ParentId)) + throw new ArgumentException("A challenge must be bound to a user and a parent session or transaction.", nameof(binding)); + if (string.IsNullOrWhiteSpace(rpId) || string.IsNullOrWhiteSpace(optionsJson)) + throw new ArgumentException("A challenge needs its relying party and server options."); + + var now = _time.GetUtcNow().UtcDateTime; + if (await _challenges.CountPendingForUserAsync(binding.UserId, now, cancellationToken) >= Math.Max(1, PasskeyConfig.MaxOutstandingChallengesPerUser)) + return null; + + var challenge = new AuthenticationChallenge + { + AuthenticationChallengeId = Guid.NewGuid().ToString(), + UserId = binding.UserId, + Purpose = (int)binding.Purpose, + ClientApplication = (int)binding.ClientApplication, + RpId = rpId, + ParentKind = (int)binding.ParentKind, + ParentId = binding.ParentId, + DepartmentId = binding.DepartmentId, + AuthenticationGeneration = binding.AuthenticationGeneration, + LockVersion = binding.LockVersion, + OptionsJson = optionsJson, + CreatedOnUtc = now, + ExpiresOnUtc = now.Add(LifetimeFor(binding.Purpose)), + MaxAttempts = Math.Max(1, PasskeyConfig.ChallengeMaxAttempts), + State = (int)AuthenticationChallengeState.Pending + }; + + await _challenges.InsertAsync(challenge, cancellationToken); + return challenge; + } + + public async Task GetForCompletionAsync(string challengeId, AuthenticationChallengeBinding binding, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(challengeId) || binding == null) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.NotFound); + + AuthenticationChallenge challenge; + try + { + challenge = await _challenges.GetAsync(challengeId, cancellationToken); + } + catch (Exception ex) + { + // Authoritative state is unavailable: the ceremony is refused, never treated as approved (plan section 3 item 4). + Logging.LogException(ex, "Authentication challenge lookup failed; the ceremony was refused."); + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.Unavailable); + } + + return Evaluate(challenge, binding, _time.GetUtcNow().UtcDateTime); + } + + public async Task TryConsumeAsync(string challengeId, CancellationToken cancellationToken = default) + { + try + { + return await _challenges.TryConsumeAsync(challengeId, _time.GetUtcNow().UtcDateTime, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, "Authentication challenge consumption failed; the ceremony was refused."); + return false; + } + } + + public Task RecordFailedAttemptAsync(string challengeId, CancellationToken cancellationToken = default) + => _challenges.RecordFailedAttemptAsync(challengeId, cancellationToken); + + public Task CancelPendingForUserAsync(string userId, CancellationToken cancellationToken = default) + => _challenges.CancelPendingForUserAsync(userId, cancellationToken); + + /// + /// Decides whether a stored challenge may be completed by this caller. Everything it was issued to must match, so a + /// request id alone never completes anything and a challenge cannot move between users, purposes or sessions. + /// + public static AuthenticationChallengeResult Evaluate(AuthenticationChallenge challenge, AuthenticationChallengeBinding binding, DateTime utcNow) + { + if (challenge == null) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.NotFound); + + if (!string.Equals(challenge.UserId, binding.UserId, StringComparison.OrdinalIgnoreCase) + || challenge.Purpose != (int)binding.Purpose + || challenge.ClientApplication != (int)binding.ClientApplication + || challenge.ParentKind != (int)binding.ParentKind + || !string.Equals(challenge.ParentId, binding.ParentId, StringComparison.Ordinal) + || challenge.DepartmentId != binding.DepartmentId + || challenge.LockVersion != binding.LockVersion) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.BindingMismatch); + + switch (challenge.ChallengeState) + { + case AuthenticationChallengeState.Consumed: + case AuthenticationChallengeState.Canceled: + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.AlreadyUsed); + case AuthenticationChallengeState.Exhausted: + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.TooManyAttempts); + } + + if (challenge.ExpiresOnUtc <= utcNow) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.Expired); + + if (challenge.Attempts >= challenge.MaxAttempts) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.TooManyAttempts); + + if (challenge.AuthenticationGeneration != binding.AuthenticationGeneration) + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.Stale); + + return AuthenticationChallengeResult.Of(AuthenticationChallengeOutcome.Usable, challenge); + } + + private static TimeSpan LifetimeFor(AuthenticationChallengePurpose purpose) => + purpose == AuthenticationChallengePurpose.PasskeyRegistration + ? TimeSpan.FromSeconds(Math.Max(30, PasskeyConfig.RegistrationChallengeLifetimeSeconds)) + : TimeSpan.FromSeconds(Math.Max(30, PasskeyConfig.AssertionChallengeLifetimeSeconds)); + } +} diff --git a/Core/Resgrid.Services/BrokerSessionAssertionService.cs b/Core/Resgrid.Services/BrokerSessionAssertionService.cs new file mode 100644 index 000000000..337c8ff8c --- /dev/null +++ b/Core/Resgrid.Services/BrokerSessionAssertionService.cs @@ -0,0 +1,252 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.IdentityModel.Tokens; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// + /// ES256 compact JWS with the algorithm pinned, issued by DataProtectionConfig.SessionAssertionIssuer for + /// DataProtectionConfig.BrokerAudience. The certificate is dedicated to assertions. Load failures log once and + /// leave the service unable to mint or validate; nothing here ever logs a token or claim value. + /// + public sealed class BrokerSessionAssertionService : IBrokerSessionAssertionService + { + private const string GenerationClaim = "auth_ver"; + private const string DepartmentClaim = "dept"; + private const string ClientClaim = "client_app"; + private const string LockVersionClaim = "session_lock_version"; + private const string RequestClaim = "req"; + private const string CredentialIssuedClaim = "cred_iat"; + + private static readonly JwtSecurityTokenHandler TokenHandler = new(); + + private readonly Lazy _signingCertificate; + private readonly Lazy _validationCertificate; + + public BrokerSessionAssertionService() + : this(LoadSigningCertificateFromConfig, LoadValidationCertificateFromConfig) + { + } + + /// Test seam: supply certificates directly instead of loading from configured paths. + public BrokerSessionAssertionService(Func signingCertificateLoader, + Func validationCertificateLoader) + { + ArgumentNullException.ThrowIfNull(signingCertificateLoader); + ArgumentNullException.ThrowIfNull(validationCertificateLoader); + + _signingCertificate = new Lazy(() => LoadSafe(signingCertificateLoader, requirePrivateKey: true), + System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); + _validationCertificate = new Lazy(() => LoadSafe(validationCertificateLoader, requirePrivateKey: false), + System.Threading.LazyThreadSafetyMode.ExecutionAndPublication); + } + + public bool CanMint => _signingCertificate.Value != null; + + public bool CanValidate => _validationCertificate.Value != null; + + public string Mint(BrokerSessionAssertion facts) + { + ArgumentNullException.ThrowIfNull(facts); + if (string.IsNullOrWhiteSpace(facts.UserId) || string.IsNullOrWhiteSpace(facts.SessionId) || facts.DepartmentId <= 0 || + facts.ClientApplication <= 0 || facts.AuthenticationGeneration < 0 || string.IsNullOrWhiteSpace(facts.RequestDigest)) + throw new ArgumentException("A session assertion needs the user, session, generation, department, client and request.", nameof(facts)); + + var certificate = _signingCertificate.Value + ?? throw new InvalidOperationException("Session-assertion signing is not configured on this host (check CanMint)."); + var ecdsa = certificate.GetECDsaPrivateKey() + ?? throw new InvalidOperationException("The session-assertion certificate does not carry an ECDSA private key."); + + var now = DateTime.UtcNow; + var lifetime = TimeSpan.FromSeconds(Math.Clamp(Config.DataProtectionConfig.SessionAssertionLifetimeSeconds, 5, 300)); + var token = new JwtSecurityToken( + issuer: Config.DataProtectionConfig.SessionAssertionIssuer, + audience: Config.DataProtectionConfig.BrokerAudience, + claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, facts.UserId) }, + notBefore: now, + expires: now.Add(lifetime), + signingCredentials: new SigningCredentials(new ECDsaSecurityKey(ecdsa), SecurityAlgorithms.EcdsaSha256)); + + var payload = token.Payload; + payload[JwtRegisteredClaimNames.Iat] = new DateTimeOffset(now).ToUnixTimeSeconds(); + payload[JwtRegisteredClaimNames.Jti] = Guid.NewGuid().ToString("N"); + payload[SessionClaimTypes.SessionId] = facts.SessionId; + payload[GenerationClaim] = facts.AuthenticationGeneration; + payload[DepartmentClaim] = facts.DepartmentId; + payload[ClientClaim] = facts.ClientApplication; + payload[RequestClaim] = facts.RequestDigest; + if (facts.SessionLockVersion != null) + payload[LockVersionClaim] = facts.SessionLockVersion.Value; + if (facts.CredentialIssuedOnUtc != null) + payload[CredentialIssuedClaim] = new DateTimeOffset(DateTime.SpecifyKind(facts.CredentialIssuedOnUtc.Value, DateTimeKind.Utc)).ToUnixTimeSeconds(); + + return TokenHandler.WriteToken(token); + } + + public BrokerSessionAssertionOutcome Validate(string token, string expectedRequestDigest, out BrokerSessionAssertion assertion, + DateTime? utcNow = null) + { + assertion = null; + var certificate = _validationCertificate.Value; + if (certificate == null) + return BrokerSessionAssertionOutcome.NotConfigured; + if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(expectedRequestDigest)) + return BrokerSessionAssertionOutcome.Invalid; + + JwtSecurityToken parsed; + try + { + var ecdsa = certificate.GetECDsaPublicKey(); + if (ecdsa == null) + return BrokerSessionAssertionOutcome.NotConfigured; + + TokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidIssuer = Config.DataProtectionConfig.SessionAssertionIssuer, + ValidAudience = Config.DataProtectionConfig.BrokerAudience, + IssuerSigningKey = new ECDsaSecurityKey(ecdsa), + ValidAlgorithms = new[] { SecurityAlgorithms.EcdsaSha256 }, + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = false, + RequireExpirationTime = true, + RequireSignedTokens = true + }, out var validated); + parsed = (JwtSecurityToken)validated; + } + catch (Exception) + { + return BrokerSessionAssertionOutcome.Invalid; + } + + var now = utcNow ?? DateTime.UtcNow; + var skew = TimeSpan.FromSeconds(Math.Max(0, Config.DataProtectionConfig.GrantClockSkewSeconds)); + if (parsed.ValidTo == DateTime.MinValue || now > parsed.ValidTo.Add(skew)) + return BrokerSessionAssertionOutcome.Expired; + if (parsed.ValidFrom == DateTime.MinValue || now < parsed.ValidFrom.Subtract(skew)) + return BrokerSessionAssertionOutcome.Invalid; + + // A long-lived assertion is not an assertion: refuse anything that claims more than the maximum lifetime. + if (parsed.ValidTo - parsed.ValidFrom > TimeSpan.FromSeconds(300)) + return BrokerSessionAssertionOutcome.Invalid; + + var payload = parsed.Payload; + var subject = payload.TryGetValue(JwtRegisteredClaimNames.Sub, out var rawSubject) ? rawSubject as string : null; + var sessionId = payload.TryGetValue(SessionClaimTypes.SessionId, out var rawSession) ? rawSession as string : null; + var request = payload.TryGetValue(RequestClaim, out var rawRequest) ? rawRequest as string : null; + if (string.IsNullOrWhiteSpace(parsed.Id) || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(sessionId) || + string.IsNullOrWhiteSpace(request) || + !TryGetInt64(payload, GenerationClaim, out var generation) || generation < 0 || + !TryGetInt64(payload, DepartmentClaim, out var department) || department <= 0 || department > int.MaxValue || + !TryGetInt64(payload, ClientClaim, out var client) || client <= 0 || client > int.MaxValue) + return BrokerSessionAssertionOutcome.Invalid; + + long? lockVersion = null; + if (payload.ContainsKey(LockVersionClaim)) + { + if (!TryGetInt64(payload, LockVersionClaim, out var parsedLock) || parsedLock < 0) + return BrokerSessionAssertionOutcome.Invalid; + lockVersion = parsedLock; + } + + DateTime? credentialIssuedOn = null; + if (payload.ContainsKey(CredentialIssuedClaim)) + { + if (!TryGetInt64(payload, CredentialIssuedClaim, out var issuedSeconds) || issuedSeconds <= 0 || + issuedSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds()) + return BrokerSessionAssertionOutcome.Invalid; + credentialIssuedOn = DateTimeOffset.FromUnixTimeSeconds(issuedSeconds).UtcDateTime; + } + + if (!CryptographicOperations.FixedTimeEquals(System.Text.Encoding.ASCII.GetBytes(request.ToUpperInvariant()), + System.Text.Encoding.ASCII.GetBytes(expectedRequestDigest.ToUpperInvariant()))) + return BrokerSessionAssertionOutcome.RequestMismatch; + + assertion = new BrokerSessionAssertion + { + UserId = subject, + SessionId = sessionId, + AuthenticationGeneration = generation, + DepartmentId = (int)department, + ClientApplication = (int)client, + SessionLockVersion = lockVersion, + CredentialIssuedOnUtc = credentialIssuedOn, + RequestDigest = request, + AssertionId = parsed.Id, + IssuedAtUtc = parsed.IssuedAt, + ExpiresOnUtc = parsed.ValidTo + }; + return BrokerSessionAssertionOutcome.Valid; + } + + private static bool TryGetInt64(JwtPayload payload, string name, out long value) + { + value = 0; + if (!payload.TryGetValue(name, out var raw)) + return false; + + switch (raw) + { + case int i: value = i; return true; + case long l: value = l; return true; + case System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Number } element: return element.TryGetInt64(out value); + default: return false; + } + } + + private static X509Certificate2 LoadSafe(Func loader, bool requirePrivateKey) + { + try + { + var certificate = loader(); + if (certificate == null) + return null; + if (requirePrivateKey ? certificate.GetECDsaPrivateKey() == null : certificate.GetECDsaPublicKey() == null) + { + Logging.LogError("The broker session-assertion certificate has no suitable ECDSA key; session assertions are disabled on this host."); + return null; + } + + return certificate; + } + catch (Exception ex) + { + Logging.LogException(ex, "The broker session-assertion certificate failed to load; session assertions are disabled on this host."); + return null; + } + } + + private static X509Certificate2 LoadSigningCertificateFromConfig() + { + var path = Config.DataProtectionConfig.SessionAssertionSigningCertificatePath; + return string.IsNullOrWhiteSpace(path) + ? null + : X509CertificateLoader.LoadPkcs12FromFile(path, Config.DataProtectionConfig.SessionAssertionSigningCertificatePassword); + } + + private static X509Certificate2 LoadValidationCertificateFromConfig() + { + var path = Config.DataProtectionConfig.SessionAssertionValidationCertificatePath; + if (string.IsNullOrWhiteSpace(path)) + return LoadSigningCertificateFromConfig(); + + try + { + return X509CertificateLoader.LoadCertificateFromFile(path); + } + catch (CryptographicException) + { + return X509CertificateLoader.LoadPkcs12FromFile(path, string.Empty); + } + } + } +} diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index 540a7da09..feb7b86ce 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -3,6 +3,7 @@ using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; using Resgrid.Model.Services; using System; using System.Collections.Generic; @@ -30,6 +31,7 @@ public class CalendarService : ICalendarService private readonly ITextResponsePromptService _textResponsePromptService; private readonly IMessageRecipientRepository _messageRecipientRepository; private readonly IUnitOfWork _unitOfWork; + private readonly Lazy _searchProjections; public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalendarItemTypeRepository calendarItemTypeRepository, ICalendarItemAttendeeRepository calendarItemAttendeeRepository, IDepartmentsService departmentsService, ICommunicationService communicationService, @@ -37,8 +39,9 @@ public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalenda IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository, IMessageRecipientRepository messageRecipientRepository, IUnitOfWork unitOfWork, Lazy protectedWriteService, - ITextResponsePromptService textResponsePromptService = null) + ITextResponsePromptService textResponsePromptService = null, Lazy searchProjections = null) { + _searchProjections = searchProjections; _protectedWriteService = protectedWriteService; _calendarItemRepository = calendarItemRepository; _calendarItemTypeRepository = calendarItemTypeRepository; @@ -127,6 +130,9 @@ public async Task> GetUpcomingCalendarItemsAsync(int departme saved = await _calendarItemRepository.SaveOrUpdateAsync(saved, cancellationToken); } + // Occurrences of a recurring series repeat the parent's text; only the parent is indexed. + if (_searchProjections != null && saved != null && string.IsNullOrWhiteSpace(saved.RecurrenceId)) + await _searchProjections.Value.ProjectCalendarItemAsync(saved, cancellationToken); return saved; } @@ -145,7 +151,12 @@ public async Task GetCalendarAttendeeByIdAsync(int calenda var item = await GetCalendarItemByIdAsync(calendarItemId); if (item != null) - return await _calendarItemRepository.DeleteAsync(item, cancellationToken); + { + var deleted = await _calendarItemRepository.DeleteAsync(item, cancellationToken); + if (deleted && _searchProjections != null) + await _searchProjections.Value.RemoveAsync(item.DepartmentId, SearchEntityTypes.CalendarEvent, item.CalendarItemId.ToString(), cancellationToken); + return deleted; + } return false; } @@ -334,7 +345,11 @@ public async Task> GetAllCalendarItemRecurrencesAsync(int cal public async Task DeleteCalendarItemAndRecurrences(int calendarItemId, CancellationToken cancellationToken = default(CancellationToken)) { - return await _calendarItemRepository.DeleteCalendarItemAndRecurrencesAsync(calendarItemId, cancellationToken); + var item = _searchProjections != null ? await GetCalendarItemByIdAsync(calendarItemId) : null; + var deleted = await _calendarItemRepository.DeleteCalendarItemAndRecurrencesAsync(calendarItemId, cancellationToken); + if (deleted && item != null) + await _searchProjections.Value.RemoveAsync(item.DepartmentId, SearchEntityTypes.CalendarEvent, item.CalendarItemId.ToString(), cancellationToken); + return deleted; } public async Task> CreateRecurrenceCalendarItemsAsync(CalendarItem item, DateTime start) diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index c18e865f7..952e6df83 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -499,6 +499,8 @@ public async Task GenerateCallFromEmail(int type, CallEmail email, string if (protectedWrite.Changed || restored) saved = await _callNotesRepository.SaveOrUpdateAsync(saved, cancellationToken); + // The note text is part of the call's search projection; refresh it so the catch-up sweep re-indexes the call. + if (_searchProjections != null) await _searchProjections.Value.ProjectCallAsync(call, cancellationToken); return saved; } diff --git a/Core/Resgrid.Services/ContactsService.cs b/Core/Resgrid.Services/ContactsService.cs index acf6dc8f8..639205e11 100644 --- a/Core/Resgrid.Services/ContactsService.cs +++ b/Core/Resgrid.Services/ContactsService.cs @@ -1,6 +1,7 @@ using System; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; using System.Collections.Generic; using System.Linq; @@ -141,7 +142,15 @@ public async Task> GetContactsByCategoryIdAsync(int departmentId, public async Task SaveContactCategoryAsync(ContactCategory category, CancellationToken cancellationToken = default(CancellationToken)) { - return await _contactCategoryRepository.SaveOrUpdateAsync(category, cancellationToken); + var isRename = category != null && !string.IsNullOrWhiteSpace(category.ContactCategoryId); + var saved = await _contactCategoryRepository.SaveOrUpdateAsync(category, cancellationToken); + + // Contact projections carry their category name. + if (isRename && saved != null && _searchProjections != null) + foreach (var contact in await _contactsRepository.GetContactsByCategoryIdAsync(saved.DepartmentId, saved.ContactCategoryId) ?? Enumerable.Empty()) + await _searchProjections.Value.ProjectContactAsync(contact, cancellationToken); + + return saved; } public async Task GetContactCategoryByIdAsync(string contactCategoryId) @@ -193,7 +202,8 @@ public async Task> GetContactNotesByContactIdAsync(string cont } } - return notes.ToList(); + // The filtered list: returning the raw rows handed deleted notes to every caller that asked for live ones. + return notesResult; } public async Task> GetContactNoteTypesByDepartmentIdAsync(int departmentId) @@ -242,6 +252,9 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s if (protectedWrite.Changed) savedNote = await _contactNotesRepository.SaveOrUpdateAsync(savedNote, cancellationToken); + // The contact's live notes are part of its search text (a deleted note leaves it the same way). + if (_searchProjections != null && savedNote != null) + await _searchProjections.Value.RefreshAsync(savedNote.DepartmentId, SearchEntityTypes.Contact, savedNote.ContactId, cancellationToken); return savedNote; } diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 9bed36f2c..592b62d36 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -52,6 +52,7 @@ public class DeleteService : IDeleteService private readonly IDeploymentService _deploymentService; private readonly IDeploymentPersonnelRepository _deploymentPersonnel; private readonly IWorkforceService _workforceService; + private readonly IMfaAccountCleanupService _mfaAccountCleanup; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -65,8 +66,10 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer IDepartmentMemberSensitiveDataService memberSensitiveDataService, IDepartmentMemberEmergencyContactService emergencyContactService, IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null, - IDeploymentService deploymentService = null, IDeploymentPersonnelRepository deploymentPersonnel = null, IWorkforceService workforceService = null) + IDeploymentService deploymentService = null, IDeploymentPersonnelRepository deploymentPersonnel = null, IWorkforceService workforceService = null, + IMfaAccountCleanupService mfaAccountCleanup = null) { + _mfaAccountCleanup = mfaAccountCleanup; _deploymentService = deploymentService; _deploymentPersonnel = deploymentPersonnel; _workforceService = workforceService; @@ -322,6 +325,10 @@ await _userSessionService.RevokeAllAsync(userIdToDelete, userIdToDelete, UserSessionRevocationReason.AccountDeactivated, System.DateTime.UtcNow, cancellationToken); await _usersService.ClearOutUserLoginAsync(userIdToDelete); + // Passkeys, evidence, pending challenges and approvals, notices and recovery state (passkey plan section 6.4). + if (_mfaAccountCleanup != null) + await _mfaAccountCleanup.RemoveForDeletedAccountAsync(userIdToDelete, actingUserId, cancellationToken); + return DeleteUserResults.NoFailure; } diff --git a/Core/Resgrid.Services/DepartmentGroupsService.cs b/Core/Resgrid.Services/DepartmentGroupsService.cs index 1206d0d70..93b8e8098 100644 --- a/Core/Resgrid.Services/DepartmentGroupsService.cs +++ b/Core/Resgrid.Services/DepartmentGroupsService.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; using Resgrid.Model.Services; using Resgrid.Providers.Bus; using Resgrid.Model.Identity; @@ -31,12 +32,15 @@ public class DepartmentGroupsService : IDepartmentGroupsService private readonly IIdentityRepository _identityRepository; private readonly IUnitOfWork _unitOfWork; private readonly IInventoryStore _inventoryStore; + private readonly Lazy _searchProjections; public DepartmentGroupsService(IDepartmentGroupsRepository departmentGroupsRepository, IDepartmentGroupMembersRepository departmentGroupMembersRepository, ISubscriptionsService subscriptionsService, IAddressService addressService, IDepartmentsService departmentsService, IGeoLocationProvider geoLocationProvider, IDepartmentSettingsService departmentSettingsService, IEventAggregator eventAggregator, ICacheProvider cacheProvider, - IIdentityRepository identityRepository, IUnitOfWork unitOfWork, IInventoryStore inventoryStore = null) + IIdentityRepository identityRepository, IUnitOfWork unitOfWork, IInventoryStore inventoryStore = null, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _departmentGroupsRepository = departmentGroupsRepository; _departmentGroupMembersRepository = departmentGroupMembersRepository; _subscriptionsService = subscriptionsService; @@ -105,10 +109,29 @@ public async Task> GetAllAsync() // Invalidate after the transaction commits so the cache is refreshed from the fully consistent state. await InvalidateGroupInCache(saved.DepartmentGroupId); SendGroupVisibilityRefresh(saved.DepartmentId); + await ProjectGroupAndDependentsAsync(saved, null, cancellationToken); return saved; } + /// The group's own projection, then its members and units (theirs carry the group name), then members who just left it. + private async Task ProjectGroupAndDependentsAsync(DepartmentGroup group, IEnumerable formerMembers, CancellationToken cancellationToken) + { + if (_searchProjections == null || group == null) + return; + await _searchProjections.Value.ProjectGroupAsync(group, cancellationToken); + await _searchProjections.Value.RefreshGroupDependentsAsync(group.DepartmentId, group.DepartmentGroupId, cancellationToken); + await RefreshPersonnelProjectionsAsync(group.DepartmentId, formerMembers, cancellationToken); + } + + private async Task RefreshPersonnelProjectionsAsync(int departmentId, IEnumerable userIds, CancellationToken cancellationToken) + { + if (_searchProjections == null || userIds == null) + return; + foreach (var userId in userIds.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase)) + await _searchProjections.Value.RefreshAsync(departmentId, SearchEntityTypes.Personnel, userId, cancellationToken); + } + public async Task> GetAllGroupsForDepartmentAsync(int departmentId) { // GetAllGroupsForDepartmentUnlimitedAsync already resolves addresses, parents and children @@ -254,9 +277,11 @@ async Task getDepartmentGroup() { var group = await GetGroupByIdAsync(groupId); if (group == null) return false; - return await InventoryHolderRetention.DeleteAsync(_inventoryStore, _unitOfWork, group.DepartmentId, groupId, false, async () => + var formerMembers = new List(); + var deleted = await InventoryHolderRetention.DeleteAsync(_inventoryStore, _unitOfWork, group.DepartmentId, groupId, false, async () => { var members = await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(groupId); + formerMembers.AddRange((members ?? Enumerable.Empty()).Select(m => m.UserId)); foreach (var departmentGroupMember in members) { @@ -269,6 +294,13 @@ async Task getDepartmentGroup() return true; }, cancellationToken); + + if (deleted && _searchProjections != null) + { + await _searchProjections.Value.RemoveAsync(group.DepartmentId, SearchEntityTypes.Group, groupId.ToString(), cancellationToken); + await RefreshPersonnelProjectionsAsync(group.DepartmentId, formerMembers, cancellationToken); + } + return deleted; } public async Task UpdateAsync(DepartmentGroup departmentGroup, CancellationToken cancellationToken = default(CancellationToken)) @@ -319,6 +351,7 @@ async Task getDepartmentGroup() await InvalidateGroupInCache(departmentGroup.DepartmentGroupId); SendGroupVisibilityRefresh(saved?.DepartmentId ?? departmentGroup.DepartmentId); + await ProjectGroupAndDependentsAsync(saved, members.Select(m => m.UserId), cancellationToken); return saved; } @@ -389,6 +422,7 @@ await _departmentGroupMembersRepository } SendGroupVisibilityRefresh(departmentId); + await RefreshPersonnelProjectionsAsync(departmentId, new[] { userId }, cancellationToken); return true; } @@ -444,6 +478,7 @@ public async Task GetGroupMemberForUserAsync(string userI await InvalidateGroupInCache(depMember.DepartmentGroupId); SendGroupVisibilityRefresh(depMember.DepartmentId); + await RefreshPersonnelProjectionsAsync(depMember.DepartmentId, new[] { depMember.UserId }, cancellationToken); return depMember; } @@ -559,6 +594,7 @@ private static Coordinates ParseCoordinates(string coordinateString) _eventAggregator.SendMessage(new UserAssignedToGroupEvent() { DepartmentId = departmentGroup.DepartmentId, UserId = userId, Group = departmentGroup }); SendGroupVisibilityRefresh(departmentGroup.DepartmentId); + await RefreshPersonnelProjectionsAsync(departmentGroup.DepartmentId, new[] { userId }, cancellationToken); return saved; } @@ -638,10 +674,14 @@ public List GetAllUsersForGroup(int groupId) public async Task DeleteGroupMembersByGroupIdAsync(int groupId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) { + var formerMembers = _searchProjections != null + ? (await _departmentGroupMembersRepository.GetAllGroupMembersByGroupIdAsync(groupId) ?? Enumerable.Empty()).Select(m => m.UserId).ToList() + : null; var result = await _departmentGroupMembersRepository.DeleteGroupMembersByGroupIdAsync(groupId, departmentId, cancellationToken); await InvalidateGroupInCache(groupId); SendGroupVisibilityRefresh(departmentId); + await RefreshPersonnelProjectionsAsync(departmentId, formerMembers, cancellationToken); return result; } diff --git a/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs index 02a5061bc..c72099054 100644 --- a/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs +++ b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -17,10 +18,13 @@ public class DepartmentMemberSensitiveDataService : IDepartmentMemberSensitiveDa // Lazy: defers the protected graph (broker client) until a save or resolve actually needs it. private readonly Lazy _protectedWriteService; private readonly Lazy _protectedReadService; + private readonly Lazy _searchProjections; public DepartmentMemberSensitiveDataService(IDepartmentMemberSensitiveDataRepository repository, - Lazy protectedWriteService, Lazy protectedReadService) + Lazy protectedWriteService, Lazy protectedReadService, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _repository = repository; _protectedWriteService = protectedWriteService; _protectedReadService = protectedReadService; @@ -129,6 +133,9 @@ public async Task SaveAsync(DepartmentMemberSensi saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); } + // The identification number is part of the member's search projection. + if (_searchProjections != null && saved != null) + await _searchProjections.Value.RefreshAsync(saved.DepartmentId, SearchEntityTypes.Personnel, saved.UserId, cancellationToken); return saved; } } diff --git a/Core/Resgrid.Services/DepartmentSsoService.cs b/Core/Resgrid.Services/DepartmentSsoService.cs index 7918e117e..d000f7c07 100644 --- a/Core/Resgrid.Services/DepartmentSsoService.cs +++ b/Core/Resgrid.Services/DepartmentSsoService.cs @@ -43,6 +43,11 @@ public class DepartmentSsoService : IDepartmentSsoService private readonly ICacheProvider _cacheProvider; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly ILimitsService _limitsService; + private readonly Resgrid.Model.Repositories.Queries.IUnitOfWork _unitOfWork; + private readonly IDepartmentDataProtectionPolicyRepository _dataProtectionPolicyRepository; + private readonly Lazy _dataProtectionService; + private readonly IUserSessionMfaEvidenceRepository _mfaEvidence; + private readonly IAuditLogsRepository _auditLogs; public DepartmentSsoService( IDepartmentSsoConfigRepository ssoConfigRepository, @@ -53,8 +58,18 @@ public DepartmentSsoService( IEncryptionService encryptionService, ICacheProvider cacheProvider, IExternalIdentityLinkService externalIdentityLinkService, - ILimitsService limitsService) + ILimitsService limitsService, + Resgrid.Model.Repositories.Queries.IUnitOfWork unitOfWork, + IDepartmentDataProtectionPolicyRepository dataProtectionPolicyRepository, + Lazy dataProtectionService, + IUserSessionMfaEvidenceRepository mfaEvidence, + IAuditLogsRepository auditLogs) { + _mfaEvidence = mfaEvidence; + _auditLogs = auditLogs; + _unitOfWork = unitOfWork; + _dataProtectionPolicyRepository = dataProtectionPolicyRepository; + _dataProtectionService = dataProtectionService; _ssoConfigRepository = ssoConfigRepository; _securityPolicyRepository = securityPolicyRepository; _departmentMembersRepository = departmentMembersRepository; @@ -104,7 +119,10 @@ public async Task SaveSsoConfigAsync(DepartmentSsoConfig co config.EncryptedSigningCertificate = EncryptNewSecret(config.EncryptedSigningCertificate, config.DepartmentId, departmentCode); config.EncryptedScimBearerToken = EncryptNewSecret(config.EncryptedScimBearerToken, config.DepartmentId, departmentCode); - return await _ssoConfigRepository.InsertAsync(config, cancellationToken); + var inserted = await _ssoConfigRepository.InsertAsync(config, cancellationToken); + if (!string.IsNullOrWhiteSpace(inserted.FederatedMfaMappingJson)) + inserted.FederatedMfaMappingVersion = await AdvanceFederatedMfaMappingAsync(inserted.DepartmentSsoConfigId, 0, cancellationToken); + return inserted; } // Blank secret fields mean "keep the stored value". The generic repository updates @@ -118,9 +136,80 @@ public async Task SaveSsoConfigAsync(DepartmentSsoConfig co config.EncryptedScimBearerToken = EncryptUpdatedSecret(config.EncryptedScimBearerToken, existing.EncryptedScimBearerToken, config.DepartmentId, departmentCode); config.UpdatedOn = DateTime.UtcNow; - return await _ssoConfigRepository.UpdateAsync(config, cancellationToken); + var updated = await _ssoConfigRepository.UpdateAsync(config, cancellationToken); + + // A provider step-up test proves one mapping against one issuer and client: changing either needs a new test, + // and what the old version verified stops counting (plan section 7.8). + if (FederatedMfaIdentityChanged(existing, config) && + (!string.IsNullOrWhiteSpace(existing.FederatedMfaMappingJson) || !string.IsNullOrWhiteSpace(config.FederatedMfaMappingJson))) + updated.FederatedMfaMappingVersion = await AdvanceFederatedMfaMappingAsync(existing.DepartmentSsoConfigId, + existing.FederatedMfaMappingVersion, cancellationToken); + + return updated; + } + + /// Every rule the policy sets, as JSON for its audit record; the same value means nothing changed. + private static string AuditSnapshot(DepartmentSecurityPolicy policy) => + System.Text.Json.JsonSerializer.Serialize(new + { + policy.RequireMfa, policy.RequireSso, policy.SessionTimeoutMinutes, policy.MaxConcurrentSessions, policy.AllowedIpRanges, + policy.PasswordExpirationDays, policy.MinPasswordLength, policy.RequirePasswordComplexity, policy.DataClassificationLevel, + policy.AllowPasskeysForLoginMfa, policy.AllowPasskeysForAdp, policy.AllowFederatedMfaForLoginMfa, policy.AllowFederatedMfaForAdp, + policy.AllowResponderApproval, policy.AcceptRecentLoginMfaForAdp, policy.AcceptRecentUnlockMfaForAdp, + policy.SharedIdleLockMinutes, policy.SharedShiftHours, policy.SharedModeRequiredApps + }); + + private static bool FederatedMfaIdentityChanged(DepartmentSsoConfig existing, DepartmentSsoConfig config) => + !string.Equals(existing.FederatedMfaMappingJson, config.FederatedMfaMappingJson, StringComparison.Ordinal) || + !string.Equals(existing.Authority, config.Authority, StringComparison.Ordinal) || + !string.Equals(existing.ClientId, config.ClientId, StringComparison.Ordinal) || + !string.Equals(existing.EntityId, config.EntityId, StringComparison.Ordinal) || + !string.Equals(existing.IdpSsoUrl, config.IdpSsoUrl, StringComparison.Ordinal) || + !string.Equals(existing.EncryptedIdpCertificate, config.EncryptedIdpCertificate, StringComparison.Ordinal); + + /// Advances the mapping version (clearing its test) and retires the evidence the previous version produced. + private async Task AdvanceFederatedMfaMappingAsync(string configId, long previousVersion, CancellationToken cancellationToken) + { + var version = await _ssoConfigRepository.AdvanceFederatedMfaMappingVersionAsync(configId, cancellationToken); + if (previousVersion > 0) + { + try + { + await _mfaEvidence.RevokeByFactorReferenceAsync(Resgrid.Model.Security.FederatedMfaMapping.FactorReferenceFor(configId, previousVersion), + DateTime.UtcNow, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The version already advanced, so nothing new can rely on the old mapping; the old evidence expires on its own. + Logging.LogException(ex, "Provider step-up evidence for a changed mapping could not be revoked."); + } + } + + return version; } + // ── Provider step-up (passkey plan section 7.8) ─────────────────────── + + public async Task GetTestedFederatedMfaConfigAsync(int departmentId, CancellationToken cancellationToken = default) + { + var config = (await _ssoConfigRepository.GetAllByDepartmentIdAsync(departmentId))?.FirstOrDefault(c => c.IsEnabled); + return Resgrid.Model.Security.FederatedMfaMapping.IsTested(config) ? config : null; + } + + public async Task IsFederatedMfaAvailableAsync(int departmentId, string userId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId) || await GetTestedFederatedMfaConfigAsync(departmentId, cancellationToken) == null) + return false; + + // Offered to members signed in through this department's provider before; the callback still checks the identity. + var members = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId); + return members?.Any(member => string.Equals(member.UserId, userId, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(member.ExternalSsoId) && !member.IsDeleted) == true; + } + + public Task RecordFederatedMfaTestAsync(string departmentSsoConfigId, long version, string userId, CancellationToken cancellationToken = default) => + _ssoConfigRepository.TryRecordFederatedMfaTestAsync(departmentSsoConfigId, version, userId, DateTime.UtcNow, cancellationToken); + public async Task DeleteSsoConfigAsync(int departmentId, SsoProviderType providerType, CancellationToken cancellationToken = default) { var config = await _ssoConfigRepository.GetByDepartmentIdAndTypeAsync(departmentId, providerType); @@ -138,10 +227,73 @@ public async Task GetSecurityPolicyForDepartmentAsync( return await _securityPolicyRepository.GetByDepartmentIdAsync(departmentId); } - public async Task SaveSecurityPolicyAsync(DepartmentSecurityPolicy policy, CancellationToken cancellationToken = default) + public Task SaveSecurityPolicyAsync(DepartmentSecurityPolicy policy, CancellationToken cancellationToken = default) => + SaveSecurityPolicyAsync(policy, null, cancellationToken); + + /// + /// Saves the policy and, in the same transaction, advances what its change invalidates (passkey plan section 10.1): + /// MfaPolicyVersion when the sign-in MFA rules move, and the ADP PolicyEpoch (revoking grants) when the rules for + /// grants move. The stored row is read under an update lock first, so concurrent changes are versioned one after + /// the other, and the version is always the server's, never the caller's. A change is written to the department's + /// audit log in the same transaction, so there is no change without its record and no record of a change that + /// rolled back. The ADP cache is cleared after commit, so no reader can re-cache the old epoch. + /// + public async Task SaveSecurityPolicyAsync(DepartmentSecurityPolicy policy, string changedByUserId, + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(policy); policy.UpdatedOn = DateTime.UtcNow; - return await _securityPolicyRepository.SaveOrUpdateAsync(policy, cancellationToken); + + var owns = _unitOfWork.Transaction == null; + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); + bool adpChanged; + DepartmentSecurityPolicy saved; + try + { + // No stored row behaves as the defaults, so a first save that departs from them still advances. + var stored = await _securityPolicyRepository.GetByDepartmentIdForUpdateAsync(policy.DepartmentId, cancellationToken) + ?? new DepartmentSecurityPolicy { DepartmentId = policy.DepartmentId }; + var mfaChanged = DepartmentSecurityPolicyDecisions.MfaPolicyChanged(stored, policy); + adpChanged = DepartmentSecurityPolicyDecisions.AdpMethodPolicyChanged(stored, policy); + + saved = await _securityPolicyRepository.SaveOrUpdateAsync(policy, cancellationToken); + saved.MfaPolicyVersion = mfaChanged + ? await _securityPolicyRepository.IncrementMfaPolicyVersionAsync(policy.DepartmentId, cancellationToken) + : stored.MfaPolicyVersion; + if (adpChanged) + await _dataProtectionPolicyRepository.IncrementPolicyEpochAsync(policy.DepartmentId, changedByUserId, cancellationToken); + + var before = AuditSnapshot(stored); + var after = AuditSnapshot(policy); + if (!string.Equals(before, after, StringComparison.Ordinal)) + await _auditLogs.SaveOrUpdateAsync(new AuditLog + { + DepartmentId = policy.DepartmentId, + ObjectDepartmentId = policy.DepartmentId, + UserId = changedByUserId, + LogType = (int)AuditLogTypes.DepartmentSecurityPolicyChanged, + LoggedOn = DateTime.UtcNow, + Successful = true, + ObjectId = saved.DepartmentSecurityPolicyId.ToString(System.Globalization.CultureInfo.InvariantCulture), + Message = "SecurityPolicyChanged", + Data = $"{{\"before\":{before},\"after\":{after},\"mfaPolicyVersion\":{saved.MfaPolicyVersion},\"adpEpochAdvanced\":{(adpChanged ? "true" : "false")}}}", + ServerName = Environment.MachineName + }, cancellationToken); + + if (owns) + _unitOfWork.CommitChanges(); + } + catch + { + if (owns) + _unitOfWork.DiscardChanges(); + throw; + } + + if (adpChanged) + await _dataProtectionService.Value.InvalidateProtectionCacheAsync(policy.DepartmentId); + + return saved; } // ── Token Validation ────────────────────────────────────────────────── @@ -169,6 +321,51 @@ public async Task ValidateExternalTokenAsync(int departmentId, } } + public async Task ValidateBrokeredSamlResponseAsync(int departmentId, string base64SamlResponse, + string departmentCode, string expectedRequestId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(expectedRequestId)) + return null; + + try + { + var config = await _ssoConfigRepository.GetByDepartmentIdAndTypeAsync(departmentId, SsoProviderType.Saml2); + if (config == null || !config.IsEnabled) + return null; + + var (principal, authnInstant, authnContexts) = await ValidateSamlResponseCoreAsync(base64SamlResponse, config, departmentCode, expectedRequestId); + return principal == null + ? null + : new Resgrid.Model.Security.SsoIdentityAssertion { Principal = principal, AuthenticatedAtUtc = authnInstant, AuthnContextClassRefs = authnContexts }; + } + catch (Exception ex) + { + Logging.LogException(ex); + return null; + } + } + + public async Task FindLinkedUserIdAsync(int departmentId, ClaimsPrincipal externalClaims, DepartmentSsoConfig config, + CancellationToken cancellationToken = default) + { + if (externalClaims == null || config == null || config.DepartmentId != departmentId) + return null; + + var mapping = ResolveAttributeMapping(config.AttributeMappingJson); + var externalSubject = GetMappedClaim(externalClaims, mapping, "subject", + ClaimTypes.NameIdentifier, "sub", "nameidentifier"); + if (string.IsNullOrWhiteSpace(externalSubject)) + return null; + + var link = await _externalIdentityLinkService.GetBySubjectAsync(config.DepartmentSsoConfigId, externalSubject, cancellationToken); + if (link != null) + return link.DepartmentId == departmentId ? link.UserId : null; + + // Accounts linked before the durable binding table existed. + var members = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId); + return members?.FirstOrDefault(candidate => string.Equals(candidate.ExternalSsoId, externalSubject, StringComparison.Ordinal))?.UserId; + } + // ── User Provisioning ───────────────────────────────────────────────── public async Task ProvisionOrLinkUserAsync(int departmentId, ClaimsPrincipal externalClaims, DepartmentSsoConfig config, string departmentCode, CancellationToken cancellationToken = default) @@ -592,19 +789,23 @@ private async Task ValidateOidcTokenAsync(string idToken, Depar } } - private static TokenValidationParameters BuildOidcValidationParameters(DepartmentSsoConfig config, OpenIdConnectConfiguration oidcConfiguration) + private static TokenValidationParameters BuildOidcValidationParameters(DepartmentSsoConfig config, OpenIdConnectConfiguration oidcConfiguration) => + BuildOidcValidationParameters(config.ClientId, oidcConfiguration.Issuer, oidcConfiguration.SigningKeys); + + /// The id_token checks shared by the legacy exchange and brokered SSO: issuer, audience, lifetime, signature. + internal static TokenValidationParameters BuildOidcValidationParameters(string clientId, string issuer, IEnumerable signingKeys) { return new TokenValidationParameters { ValidateIssuer = true, - ValidIssuer = oidcConfiguration.Issuer, + ValidIssuer = issuer, ValidateAudience = true, - ValidAudience = config.ClientId, + ValidAudience = clientId, ValidateLifetime = true, RequireExpirationTime = true, ValidateIssuerSigningKey = true, RequireSignedTokens = true, - IssuerSigningKeys = oidcConfiguration.SigningKeys, + IssuerSigningKeys = signingKeys, ClockSkew = TokenClockSkew }; } @@ -613,58 +814,113 @@ private async Task ValidateSamlResponseAsync(string base64SamlR { try { - if (string.IsNullOrWhiteSpace(base64SamlResponse) || base64SamlResponse.Length > 2_800_000 || - string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate) || string.IsNullOrWhiteSpace(config.EntityId) || - string.IsNullOrWhiteSpace(config.AssertionConsumerServiceUrl)) - return null; + // The legacy relay accepts IdP-initiated (unsolicited) responses; brokered SSO never does. + return (await ValidateSamlResponseCoreAsync(base64SamlResponse, config, departmentCode, expectedInResponseTo: null)).Principal; + } + catch (Exception ex) + { + Logging.LogException(ex); + return null; + } + } - var samlBytes = Convert.FromBase64String(base64SamlResponse); - if (samlBytes.Length > 2_000_000) - return null; + /// + /// Every SAML check, and when is set, the binding to that AuthnRequest: the + /// signed element must carry it (the Response when the Response is signed, otherwise the assertion's bearer + /// SubjectConfirmationData), and no InResponseTo anywhere may name another request. + /// + private async Task<(ClaimsPrincipal Principal, DateTime? AuthnInstant, IReadOnlyCollection AuthnContexts)> ValidateSamlResponseCoreAsync(string base64SamlResponse, + DepartmentSsoConfig config, string departmentCode, string expectedInResponseTo) + { + if (string.IsNullOrWhiteSpace(base64SamlResponse) || base64SamlResponse.Length > 2_800_000 || + string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate) || string.IsNullOrWhiteSpace(config.EntityId) || + string.IsNullOrWhiteSpace(config.AssertionConsumerServiceUrl)) + return default; - var document = LoadSamlDocument(samlBytes); - var response = document.DocumentElement; - if (response == null || response.LocalName != "Response" || response.NamespaceURI != "urn:oasis:names:tc:SAML:2.0:protocol") - return null; + var samlBytes = Convert.FromBase64String(base64SamlResponse); + if (samlBytes.Length > 2_000_000) + return default; - var namespaces = new XmlNamespaceManager(document.NameTable); - namespaces.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); - namespaces.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); - namespaces.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); + var document = LoadSamlDocument(samlBytes); + var response = document.DocumentElement; + if (response == null || response.LocalName != "Response" || response.NamespaceURI != "urn:oasis:names:tc:SAML:2.0:protocol") + return default; - var statusCode = response.SelectSingleNode("./samlp:Status/samlp:StatusCode", namespaces) as XmlElement; - if (statusCode?.GetAttribute("Value") != "urn:oasis:names:tc:SAML:2.0:status:Success") - return null; + var namespaces = new XmlNamespaceManager(document.NameTable); + namespaces.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); + namespaces.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); + namespaces.AddNamespace("ds", SignedXml.XmlDsigNamespaceUrl); - var assertionNodes = response.SelectNodes("./saml:Assertion", namespaces); - if (assertionNodes?.Count != 1 || assertionNodes[0] is not XmlElement assertion || !HasUniqueSamlIds(document)) - return null; + var statusCode = response.SelectSingleNode("./samlp:Status/samlp:StatusCode", namespaces) as XmlElement; + if (statusCode?.GetAttribute("Value") != "urn:oasis:names:tc:SAML:2.0:status:Success") + return default; - var certificatePem = _encryptionService.DecryptForDepartment( - config.EncryptedIdpCertificate, config.DepartmentId, departmentCode); - using var certificate = X509Certificate2.CreateFromPem(certificatePem); + var assertionNodes = response.SelectNodes("./saml:Assertion", namespaces); + if (assertionNodes?.Count != 1 || assertionNodes[0] is not XmlElement assertion || !HasUniqueSamlIds(document)) + return default; - var now = DateTime.UtcNow; - if (now + TokenClockSkew < certificate.NotBefore.ToUniversalTime() || now - TokenClockSkew >= certificate.NotAfter.ToUniversalTime()) - return null; + var certificatePem = _encryptionService.DecryptForDepartment( + config.EncryptedIdpCertificate, config.DepartmentId, departmentCode); + using var certificate = X509Certificate2.CreateFromPem(certificatePem); - if (!ValidateSamlSignature(document, response, assertion, namespaces, certificate) || - !ValidateSamlDestinationAndConditions(response, assertion, namespaces, config, now, out var assertionExpiresOn)) - return null; + var now = DateTime.UtcNow; + if (now + TokenClockSkew < certificate.NotBefore.ToUniversalTime() || now - TokenClockSkew >= certificate.NotAfter.ToUniversalTime()) + return default; - var assertionId = assertion.GetAttribute("ID"); - if (string.IsNullOrWhiteSpace(assertionId) || - !await MarkSamlAssertionConsumedAsync(config.DepartmentSsoConfigId, assertionId, assertionExpiresOn, now)) - return null; + if (!ValidateSamlSignature(document, response, assertion, namespaces, certificate, out var signedElement) || + !ValidateSamlDestinationAndConditions(response, assertion, namespaces, config, now, out var assertionExpiresOn)) + return default; - var claims = ExtractSamlClaims(assertion, namespaces); - return claims.Count == 0 ? null : new ClaimsPrincipal(new ClaimsIdentity(claims, "SAML2")); - } - catch (Exception ex) + if (expectedInResponseTo != null && !IsBoundToRequest(response, assertion, signedElement, namespaces, expectedInResponseTo)) + return default; + + var assertionId = assertion.GetAttribute("ID"); + if (string.IsNullOrWhiteSpace(assertionId) || + !await MarkSamlAssertionConsumedAsync(config.DepartmentSsoConfigId, assertionId, assertionExpiresOn, now)) + return default; + + var claims = ExtractSamlClaims(assertion, namespaces); + if (claims.Count == 0) + return default; + + DateTime? authnInstant = null; + if (assertion.SelectSingleNode("./saml:AuthnStatement", namespaces) is XmlElement authnStatement && + TryReadSamlInstant(authnStatement.GetAttribute("AuthnInstant"), out var instant)) { - Logging.LogException(ex); - return null; + authnInstant = instant; + // Under the claim an id_token carries, for the legacy exchange's check on shared installations (section 12.5.2). + claims.Add(new Claim(Resgrid.Model.Security.ProviderSignInTime.ClaimType, Resgrid.Model.Security.ProviderSignInTime.ClaimValue(instant), + ClaimValueTypes.Integer64)); } + + // How the IdP says it authenticated the user, for provider step-up mappings (plan section 7.8). + var authnContexts = assertion.SelectNodes("./saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef", namespaces)? + .Cast().Select(node => node.InnerText.Trim()).Where(value => value.Length > 0).ToList() ?? new List(); + + return (new ClaimsPrincipal(new ClaimsIdentity(claims, "SAML2")), authnInstant, authnContexts); + } + + /// + /// A brokered response must answer our AuthnRequest in a place the signature covers: the Response itself when it is + /// the signed element, otherwise the assertion's bearer SubjectConfirmationData. No InResponseTo may name another. + /// + private static bool IsBoundToRequest(XmlElement response, XmlElement assertion, XmlElement signedElement, XmlNamespaceManager namespaces, + string expectedInResponseTo) + { + var responseInResponseTo = response.GetAttribute("InResponseTo"); + if (!string.IsNullOrEmpty(responseInResponseTo) && !string.Equals(responseInResponseTo, expectedInResponseTo, StringComparison.Ordinal)) + return false; + + var confirmations = assertion.SelectNodes( + "./saml:Subject/saml:SubjectConfirmation[@Method='urn:oasis:names:tc:SAML:2.0:cm:bearer']/saml:SubjectConfirmationData", namespaces)? + .Cast().ToList() ?? new List(); + if (confirmations.Any(data => data.HasAttribute("InResponseTo") && + !string.Equals(data.GetAttribute("InResponseTo"), expectedInResponseTo, StringComparison.Ordinal))) + return false; + + return ReferenceEquals(signedElement, response) + ? string.Equals(responseInResponseTo, expectedInResponseTo, StringComparison.Ordinal) + : confirmations.Any(data => string.Equals(data.GetAttribute("InResponseTo"), expectedInResponseTo, StringComparison.Ordinal)); } private static XmlDocument LoadSamlDocument(byte[] samlBytes) @@ -701,10 +957,10 @@ private static bool HasUniqueSamlIds(XmlDocument document) } private static bool ValidateSamlSignature(XmlDocument document, XmlElement response, XmlElement assertion, - XmlNamespaceManager namespaces, X509Certificate2 certificate) + XmlNamespaceManager namespaces, X509Certificate2 certificate, out XmlElement signedElement) { var signature = assertion.SelectSingleNode("./ds:Signature", namespaces) as XmlElement; - var signedElement = assertion; + signedElement = assertion; if (signature == null) { signature = response.SelectSingleNode("./ds:Signature", namespaces) as XmlElement; diff --git a/Core/Resgrid.Services/ExternalIdentityLinkService.cs b/Core/Resgrid.Services/ExternalIdentityLinkService.cs index 6010a6977..0a133c1a1 100644 --- a/Core/Resgrid.Services/ExternalIdentityLinkService.cs +++ b/Core/Resgrid.Services/ExternalIdentityLinkService.cs @@ -42,6 +42,10 @@ public async Task SaveAsync(UserExternalIdentityLink l return await _linksRepository.SaveOrUpdateAsync(link, cancellationToken, true); } + public async Task> GetActiveLinksAsync(string userId, + CancellationToken cancellationToken = default) => + (await _linksRepository.GetActiveByUserAsync(userId)).ToList(); + public async Task GetSsoManagementStateAsync(string userId, CancellationToken cancellationToken = default) { var links = await _linksRepository.GetActiveByUserAsync(userId); diff --git a/Core/Resgrid.Services/FactorRecoveryService.cs b/Core/Resgrid.Services/FactorRecoveryService.cs new file mode 100644 index 000000000..0d1b068ad --- /dev/null +++ b/Core/Resgrid.Services/FactorRecoveryService.cs @@ -0,0 +1,129 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class FactorRecoveryService : IFactorRecoveryService + { + private const int MaxSecretLength = 128; + + private readonly IFactorRecoveryTransactionRepository _transactions; + private readonly IIdentityUserRepository _identityUsers; + private readonly TimeProvider _time; + + public FactorRecoveryService(IFactorRecoveryTransactionRepository transactions, IIdentityUserRepository identityUsers, TimeProvider time) + { + _transactions = transactions; + _identityUsers = identityUsers; + _time = time; + } + + public bool IsEnabled => TwoFactorConfig.LoginMfaTransactionEnabled; + + private static TimeSpan Lifetime => TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.FactorRecoveryLifetimeMinutes)); + + public async Task BeginAsync(MfaLoginTransaction login, CancellationToken cancellationToken = default) + { + if (login == null || string.IsNullOrWhiteSpace(login.UserId)) + throw new ArgumentException("A recovery starts from a verified first factor.", nameof(login)); + + var now = _time.GetUtcNow().UtcDateTime; + var secret = NewSecret(); + var transaction = new FactorRecoveryTransaction + { + FactorRecoveryTransactionId = Guid.NewGuid().ToString(), + SecretHash = Hash(secret), + UserId = login.UserId, + ClientApplication = login.ClientApplication, + FirstFactorMethod = login.FirstFactorMethod, + FirstFactorVerifiedOnUtc = login.FirstFactorVerifiedOnUtc, + DepartmentSsoConfigId = login.DepartmentSsoConfigId, + DepartmentId = login.DepartmentId, + AuthenticationGeneration = login.AuthenticationGeneration, + CreatedOnUtc = now, + ExpiresOnUtc = now.Add(Lifetime), + MaxAttempts = Math.Max(1, TwoFactorConfig.FactorRecoveryMaxAttempts), + State = (int)FactorRecoveryState.Pending + }; + await _transactions.InsertAsync(transaction, cancellationToken); + return new FactorRecoveryStart { Secret = secret, ExpiresInSeconds = (int)Lifetime.TotalSeconds, Transaction = transaction }; + } + + public async Task OpenAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(secret) || secret.Length > MaxSecretLength) + return FactorRecoveryResult.Of(FactorRecoveryOutcome.Invalid); + + try + { + var transaction = await _transactions.GetBySecretHashAsync(Hash(secret), cancellationToken); + if (transaction == null || transaction.ClientApplication != (int)client) + return FactorRecoveryResult.Of(FactorRecoveryOutcome.Invalid); + + var now = _time.GetUtcNow().UtcDateTime; + var outcome = transaction.RecoveryState switch + { + FactorRecoveryState.Pending when transaction.ExpiresOnUtc <= now => FactorRecoveryOutcome.Expired, + FactorRecoveryState.Pending when transaction.Attempts >= transaction.MaxAttempts => FactorRecoveryOutcome.TooManyAttempts, + FactorRecoveryState.Pending => FactorRecoveryOutcome.Usable, + FactorRecoveryState.Exhausted => FactorRecoveryOutcome.TooManyAttempts, + _ => FactorRecoveryOutcome.AlreadyUsed + }; + if (outcome != FactorRecoveryOutcome.Usable) + return FactorRecoveryResult.Of(outcome); + + // A password change or any revocation since the recovery began voids it (plan section 5.4). + var user = await _identityUsers.GetByIdAsync(transaction.UserId); + return user == null || user.AuthenticationGeneration != transaction.AuthenticationGeneration + ? FactorRecoveryResult.Of(FactorRecoveryOutcome.SessionRevoked) + : FactorRecoveryResult.Of(FactorRecoveryOutcome.Usable, transaction); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // Authoritative state is unavailable: recovery is refused, never assumed. + Logging.LogException(ex, "A factor recovery could not be read; it was refused."); + return FactorRecoveryResult.Of(FactorRecoveryOutcome.Unavailable); + } + } + + public async Task RecordFailedAttemptAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default) + { + try + { + await _transactions.RecordFailedAttemptAsync(transaction.FactorRecoveryTransactionId, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A failed factor recovery attempt could not be counted."); + } + } + + public Task TryCompleteAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default) => + _transactions.TryCompleteAsync(transaction.FactorRecoveryTransactionId, _time.GetUtcNow().UtcDateTime, cancellationToken); + + public async Task CancelAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default) + { + var opened = await OpenAsync(secret, client, cancellationToken); + if (!opened.IsUsable) + return opened.Outcome; + + return await _transactions.TryCancelAsync(opened.Transaction.FactorRecoveryTransactionId, cancellationToken) + ? FactorRecoveryOutcome.Usable + : FactorRecoveryOutcome.AlreadyUsed; + } + + private static string NewSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Hash(string secret) => SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + } +} diff --git a/Core/Resgrid.Services/MappingService.cs b/Core/Resgrid.Services/MappingService.cs index 96d1394c9..8fd61d4e5 100644 --- a/Core/Resgrid.Services/MappingService.cs +++ b/Core/Resgrid.Services/MappingService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -15,10 +16,12 @@ public class MappingService : IMappingService private readonly IPoisRepository _poisRepository; private readonly Lazy> _mapLayersRepository; private readonly IMapLayersDocRepository _mapLayersDocRepository; + private readonly Lazy _searchProjections; public MappingService(IPoiTypesRepository poiTypesRepository, IPoisRepository poisRepository, Lazy> mapLayersRepository, - IMapLayersDocRepository mapLayersDocRepository) + IMapLayersDocRepository mapLayersDocRepository, Lazy searchProjections = null) { + _searchProjections = searchProjections; _poiTypesRepository = poiTypesRepository; _poisRepository = poisRepository; _mapLayersRepository = mapLayersRepository; @@ -27,14 +30,28 @@ public MappingService(IPoiTypesRepository poiTypesRepository, IPoisRepository po public async Task SavePOITypeAsync(PoiType type, CancellationToken cancellationToken = default(CancellationToken)) { - return await _poiTypesRepository.SaveOrUpdateAsync(type, cancellationToken); + var saved = await _poiTypesRepository.SaveOrUpdateAsync(type, cancellationToken); + // Each POI's projection carries its type name; a renamed type re-projects them. + if (_searchProjections != null && saved != null && type != null && type.PoiTypeId > 0) + { + var stored = await _poiTypesRepository.GetPoiTypeByTypeIdAsync(saved.PoiTypeId); + foreach (var poi in stored?.Pois ?? Enumerable.Empty()) + { + poi.Type = stored; + await _searchProjections.Value.ProjectPoiAsync(poi, cancellationToken); + } + } + + return saved; } public async Task SavePOIAsync(Poi poi, CancellationToken cancellationToken = default(CancellationToken)) { - return await _poisRepository.SaveOrUpdateAsync(poi, cancellationToken); - + var saved = await _poisRepository.SaveOrUpdateAsync(poi, cancellationToken); + if (_searchProjections != null && saved != null) + await _searchProjections.Value.ProjectPoiAsync(saved, cancellationToken); + return saved; } public async Task> GetPOITypesForDepartmentAsync(int departmentId) @@ -105,7 +122,11 @@ public async Task GetTypeByIdAsync(int poiTypeId) if (type != null) { - return await _poiTypesRepository.DeleteAsync(type, cancellationToken); + var deleted = await _poiTypesRepository.DeleteAsync(type, cancellationToken); + if (deleted && _searchProjections != null) + foreach (var poi in type.Pois ?? Enumerable.Empty()) + await _searchProjections.Value.RemoveAsync(type.DepartmentId, SearchEntityTypes.Poi, poi.PoiId.ToString(), cancellationToken); + return deleted; } return false; @@ -117,7 +138,11 @@ public async Task GetTypeByIdAsync(int poiTypeId) if (poi != null) { - return await _poisRepository.DeleteAsync(poi, cancellationToken); + var type = _searchProjections != null ? await GetTypeByIdAsync(poi.PoiTypeId) : null; + var deleted = await _poisRepository.DeleteAsync(poi, cancellationToken); + if (deleted && type != null) + await _searchProjections.Value.RemoveAsync(type.DepartmentId, SearchEntityTypes.Poi, poi.PoiId.ToString(), cancellationToken); + return deleted; } return false; diff --git a/Core/Resgrid.Services/MfaAccountCleanupService.cs b/Core/Resgrid.Services/MfaAccountCleanupService.cs new file mode 100644 index 000000000..64db48dd6 --- /dev/null +++ b/Core/Resgrid.Services/MfaAccountCleanupService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class MfaAccountCleanupService : IMfaAccountCleanupService + { + private readonly IUserPasskeyRepository _passkeys; + private readonly IUserSessionMfaEvidenceRepository _evidence; + private readonly IAuthenticationChallengeRepository _challenges; + private readonly IMfaApprovalRequestRepository _approvals; + private readonly ISecurityNoticeRepository _notices; + private readonly IFactorRecoveryTransactionRepository _recoveries; + private readonly IMfaActivityRepository _activity; + private readonly TimeProvider _time; + + public MfaAccountCleanupService(IUserPasskeyRepository passkeys, IUserSessionMfaEvidenceRepository evidence, + IAuthenticationChallengeRepository challenges, IMfaApprovalRequestRepository approvals, ISecurityNoticeRepository notices, + IFactorRecoveryTransactionRepository recoveries, IMfaActivityRepository activity, TimeProvider time) + { + _activity = activity; + _passkeys = passkeys; + _evidence = evidence; + _challenges = challenges; + _approvals = approvals; + _notices = notices; + _recoveries = recoveries; + _time = time; + } + + public async Task RemoveForDeletedAccountAsync(string userId, string actorUserId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) + return; + + var now = _time.GetUtcNow().UtcDateTime; + + // Each step stands alone: one failing never leaves the others undone. + await StepAsync("revoke passkeys", async () => + { + foreach (var passkey in await _passkeys.GetActiveForUserAsync(userId, cancellationToken)) + await _passkeys.TryRevokeAsync(passkey.UserPasskeyId, userId, PasskeyRevocationReason.AccountDeactivated, actorUserId, now, cancellationToken); + }); + await StepAsync("retire evidence", () => _evidence.RevokeForUserAsync(userId, now, cancellationToken)); + await StepAsync("cancel challenges", () => _challenges.CancelPendingForUserAsync(userId, cancellationToken)); + await StepAsync("cancel approval requests", () => _approvals.CancelPendingForUserAsync(userId, MfaApprovalEndReason.ApproverRevoked, now, cancellationToken)); + await StepAsync("remove notices", () => _notices.DeleteForUserAsync(userId, cancellationToken)); + await StepAsync("remove recovery transactions", () => _recoveries.DeleteForUserAsync(userId, cancellationToken)); + await StepAsync("remove MFA activity", () => _activity.DeleteForUserAsync(userId, cancellationToken)); + } + + private static async Task StepAsync(string step, Func action) + { + try + { + await action(); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, $"Account deletion could not {step}."); + } + } + } +} diff --git a/Core/Resgrid.Services/MfaActivityRecords.cs b/Core/Resgrid.Services/MfaActivityRecords.cs new file mode 100644 index 000000000..3f400c2c4 --- /dev/null +++ b/Core/Resgrid.Services/MfaActivityRecords.cs @@ -0,0 +1,34 @@ +using System; +using Resgrid.Model; +using Resgrid.Model.Security; + +namespace Resgrid.Services +{ + /// Builds an activity row from what a caller knows and the session it was for (plan section 6.5). + public static class MfaActivityRecords + { + public static MfaActivity Create(MfaActivityEntry entry, UserSession session, DateTime occurredOnUtc) => new() + { + MfaActivityId = Guid.NewGuid().ToString(), + UserId = entry.UserId, + OccurredOnUtc = occurredOnUtc, + Method = (int)entry.Method, + Purpose = (int)entry.Purpose, + Successful = entry.Successful, + ClientApplication = (int)entry.ClientApplication, + InstallationLabel = Limit(entry.InstallationLabel ?? session?.DeviceName), + SharedMode = entry.SharedMode || session?.SharedMode == true, + DepartmentId = entry.DepartmentId, + SessionId = Limit(entry.SessionId), + ApproverSessionId = Limit(entry.ApproverSessionId) + }; + + private static string Limit(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var sanitized = value.Replace("\r", " ").Replace("\n", " ").Trim(); + return sanitized.Length <= 256 ? sanitized : sanitized.Substring(0, 256); + } + } +} diff --git a/Core/Resgrid.Services/MfaActivityService.cs b/Core/Resgrid.Services/MfaActivityService.cs new file mode 100644 index 000000000..c4d726de8 --- /dev/null +++ b/Core/Resgrid.Services/MfaActivityService.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class MfaActivityService : IMfaActivityService + { + private const int ViewSize = 100; + + private readonly IMfaActivityRepository _rows; + private readonly IUserSessionsRepository _sessions; + private readonly IUserSessionService _userSessions; + private readonly ISecurityNoticeService _notices; + private readonly ISystemAuditsService _audits; + private readonly TimeProvider _time; + + public MfaActivityService(IMfaActivityRepository rows, IUserSessionsRepository sessions, IUserSessionService userSessions, + ISecurityNoticeService notices, ISystemAuditsService audits, TimeProvider time) + { + _rows = rows; + _sessions = sessions; + _userSessions = userSessions; + _notices = notices; + _audits = audits; + _time = time; + } + + public async Task RecordAsync(MfaActivityEntry entry, CancellationToken cancellationToken = default) + { + if (entry == null || string.IsNullOrWhiteSpace(entry.UserId)) + return; + + try + { + var now = _time.GetUtcNow().UtcDateTime; + // Plan section 13: failed attempts are bounded, so guessing cannot fill the table. The lockout still counts them. + if (!entry.Successful && + await _rows.CountDeniedSinceAsync(entry.UserId, now.AddHours(-1), cancellationToken) >= Math.Max(1, TwoFactorConfig.MfaActivityDeniedPerHour)) + return; + + var session = !string.IsNullOrWhiteSpace(entry.SessionId) && string.IsNullOrWhiteSpace(entry.InstallationLabel) + ? await _sessions.GetByIdAsync(entry.SessionId) + : null; + await _rows.InsertAsync(MfaActivityRecords.Create(entry, session, now), cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The verification it describes already happened (or was refused); a missing history row changes neither. + Logging.LogException(ex, "MFA activity could not be recorded."); + } + } + + public Task> GetRecentAsync(string userId, CancellationToken cancellationToken = default) => + _rows.GetRecentAsync(userId, RetentionCutoff(), ViewSize, cancellationToken); + + public async Task ReportAsync(string userId, string mfaActivityId, string reportingSessionId, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default) + { + var activity = string.IsNullOrWhiteSpace(mfaActivityId) ? null : await _rows.GetAsync(mfaActivityId, cancellationToken); + if (activity == null || !string.Equals(activity.UserId, userId, StringComparison.OrdinalIgnoreCase) || activity.OccurredOnUtc < RetentionCutoff()) + return new MfaActivityReport { Outcome = MfaActivityReportOutcome.NotFound }; + + var now = _time.GetUtcNow().UtcDateTime; + if (!await _rows.TryMarkReportedAsync(activity.MfaActivityId, userId, now, cancellationToken)) + return new MfaActivityReport { Outcome = MfaActivityReportOutcome.AlreadyReported }; + + // A verification that someone else made opened or served a session: that session ends now. The reporting session + // stays, so the user can change the password from it. + var ended = false; + if (activity.Successful && !string.IsNullOrWhiteSpace(activity.SessionId) && + !string.Equals(activity.SessionId, reportingSessionId, StringComparison.Ordinal)) + ended = (await _userSessions.RevokeSessionAsync(userId, userId, activity.SessionId, UserSessionRevocationReason.AccountCompromised, + cancellationToken)).RevokedSessionCount > 0; + + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.MfaActivityReported, + UserId = userId, + Username = request?.UserName, + TargetUserId = userId, + SessionId = SharedSessionAudit.SessionSuffix(activity.SessionId), + Successful = true, + IpAddress = request?.IpAddress, + ServerName = Environment.MachineName, + CorrelationId = request?.CorrelationId, + Data = $"MFA activity {activity.MfaActivityId} reported as not the account holder's " + + $"({(MfaEvidenceMethod)activity.Method}, {(activity.Successful ? "successful" : "denied")}); session ended: {ended}.", + LoggedOn = now + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "MFA activity report audit failed."); + } + + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = userId, + Kind = SecurityNoticeKind.ActivityReported, + ClientApplication = (UserSessionClientApplication)activity.ClientApplication, + InstallationLabel = activity.InstallationLabel + }, cancellationToken); + + return new MfaActivityReport { Outcome = MfaActivityReportOutcome.Reported, SessionEnded = ended }; + } + + private DateTime RetentionCutoff() => _time.GetUtcNow().UtcDateTime.AddDays(-Math.Max(1, TwoFactorConfig.MfaActivityRetentionDays)); + } +} diff --git a/Core/Resgrid.Services/MfaApprovalService.cs b/Core/Resgrid.Services/MfaApprovalService.cs new file mode 100644 index 000000000..0e12314c2 --- /dev/null +++ b/Core/Resgrid.Services/MfaApprovalService.cs @@ -0,0 +1,697 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class MfaApprovalService : IMfaApprovalService + { + internal const string PushTitle = "Sign-in approval requested"; + internal const string PushBody = "Open Resgrid Responder to review."; + + /// The Novu event code prefix Responder recognizes; the push carries nothing else (workbook section 7.4). + internal const string PushEventPrefix = "NA:"; + + private readonly IMfaApprovalRequestRepository _requests; + private readonly IUserPasskeyRepository _passkeyRows; + private readonly IUserSessionsRepository _sessions; + private readonly IIdentityUserRepository _identityUsers; + private readonly IMfaLoginTransactionRepository _loginTransactions; + private readonly IPasskeyService _passkeys; + private readonly IRelyingPartyRegistry _registry; + private readonly IPasskeyFeatureGates _gates; + private readonly IMfaPolicyService _policy; + private readonly IDepartmentsService _departments; + private readonly INovuProvider _novu; + private readonly IIpLocationProvider _location; + private readonly ISystemAuditsService _audits; + private readonly ISecurityNoticeService _notices; + private readonly ISessionEventPublisher _sessionEvents; + private readonly IMfaActivityService _activity; + private readonly TimeProvider _time; + + public MfaApprovalService(IMfaApprovalRequestRepository requests, IUserPasskeyRepository passkeyRows, IUserSessionsRepository sessions, + IIdentityUserRepository identityUsers, IMfaLoginTransactionRepository loginTransactions, IPasskeyService passkeys, IRelyingPartyRegistry registry, + IPasskeyFeatureGates gates, IMfaPolicyService policy, IDepartmentsService departments, INovuProvider novu, IIpLocationProvider location, + ISystemAuditsService audits, ISecurityNoticeService notices, ISessionEventPublisher sessionEvents, IMfaActivityService activity, TimeProvider time) + { + _activity = activity; + _sessionEvents = sessionEvents; + _notices = notices; + _requests = requests; + _passkeyRows = passkeyRows; + _sessions = sessions; + _identityUsers = identityUsers; + _loginTransactions = loginTransactions; + _passkeys = passkeys; + _registry = registry; + _gates = gates; + _policy = policy; + _departments = departments; + _novu = novu; + _location = location; + _audits = audits; + _time = time; + } + + private static TimeSpan Lifetime => TimeSpan.FromSeconds(Math.Max(30, PasskeyConfig.ApprovalRequestLifetimeSeconds)); + private static TimeSpan RateWindow => TimeSpan.FromMinutes(Math.Max(1, PasskeyConfig.ApprovalRateWindowMinutes)); + private static TimeSpan SuspensionWindow => TimeSpan.FromMinutes(Math.Max(1, PasskeyConfig.ApprovalSuspensionMinutes)); + private static TimeSpan ConsumeGrace => TimeSpan.FromSeconds(Math.Max(0, PasskeyConfig.ApprovalConsumeGraceSeconds)); + + public bool IsEnabled => _gates.ResponderApprovalEnabled; + + public async Task IsAvailableAsync(string userId, UserSessionClientApplication requestingClient, CancellationToken cancellationToken = default) + { + if (!IsEnabled || requestingClient == UserSessionClientApplication.Responder || string.IsNullOrWhiteSpace(userId)) + return false; + + try + { + return (await EligibleApproverSessionsAsync(userId, null, cancellationToken)).Count > 0; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // Only whether to offer the method: without an answer it is simply not offered. + Logging.LogException(ex, "Responder approval availability could not be read."); + return false; + } + } + + // ── Requester ───────────────────────────────────────────────────────────────── + + public async Task RequestAsync(MfaApprovalRequester requester, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + return MfaApprovalStart.Of(MfaApprovalOutcome.Unavailable); + if (requester == null || string.IsNullOrWhiteSpace(requester.UserId) || string.IsNullOrWhiteSpace(requester.RequesterId)) + return MfaApprovalStart.Of(MfaApprovalOutcome.InvalidRequest); + if (requester.Purpose == MfaApprovalPurpose.StepUp && !MfaStepUpOperations.IsKnown(requester.Operation)) + return MfaApprovalStart.Of(MfaApprovalOutcome.InvalidRequest); + + // Responder approves; it never asks. Department entry arrives with its slice. An unlock is asked for by a locked shared + // session and is bound to its lock version, so a lock or operator change voids it (plan section 7.9). Protected data is + // asked for by a signed-in session in one department, never by a sign-in that has no session yet. + if (requester.ClientApplication == UserSessionClientApplication.Responder || + requester.Purpose is not (MfaApprovalPurpose.Login or MfaApprovalPurpose.StepUp or MfaApprovalPurpose.Adp or MfaApprovalPurpose.Unlock)) + return MfaApprovalStart.Of(MfaApprovalOutcome.Unavailable); + if (requester.Purpose == MfaApprovalPurpose.Unlock && + (requester.Kind != MfaApprovalRequesterKind.Session || !requester.SharedMode || requester.LockVersion == null)) + return MfaApprovalStart.Of(MfaApprovalOutcome.InvalidRequest); + if (requester.Purpose == MfaApprovalPurpose.Adp && (requester.Kind != MfaApprovalRequesterKind.Session || requester.DepartmentId is not > 0)) + return MfaApprovalStart.Of(MfaApprovalOutcome.InvalidRequest); + + try + { + // The department must accept approval for this row: never security changes or account factors (plan section 7.6). + if (!await _policy.IsMethodAcceptedAsync(requester.DepartmentId, requester.Scope, MfaEvidenceMethod.PasskeyApproval, cancellationToken)) + return MfaApprovalStart.Of(MfaApprovalOutcome.Unavailable); + + var user = await _identityUsers.GetByIdAsync(requester.UserId); + if (user == null || user.AuthenticationGeneration != requester.AuthenticationGeneration) + return MfaApprovalStart.Of(MfaApprovalOutcome.NotFound); + + var now = _time.GetUtcNow().UtcDateTime; + if (await IsSuspendedAsync(requester.UserId, now, cancellationToken)) + { + // Expiries only show up here, so the pause is announced once per window (plan section 7.9 abuse controls). + await _notices.QueueOnceAsync(new SecurityNoticeRequest { UserId = requester.UserId, Kind = SecurityNoticeKind.ApprovalSuspended }, + SuspensionWindow, cancellationToken); + return MfaApprovalStart.Of(MfaApprovalOutcome.Suspended); + } + if (await _requests.CountCreatedSinceAsync(requester.UserId, now - RateWindow, cancellationToken) >= Math.Max(1, PasskeyConfig.ApprovalMaxRequestsPerWindow)) + return MfaApprovalStart.Of(MfaApprovalOutcome.TooManyRequests); + + var approvers = await EligibleApproverSessionsAsync(requester.UserId, + requester.Kind == MfaApprovalRequesterKind.Session ? requester.RequesterId : null, cancellationToken); + if (approvers.Count == 0) + return MfaApprovalStart.Of(MfaApprovalOutcome.Unavailable); + + // One pending request per user: a new one replaces the old, which can no longer be approved. + await _requests.CancelPendingForUserAsync(requester.UserId, MfaApprovalEndReason.Superseded, now, cancellationToken); + + var (label, region) = await RequesterContextAsync(requester, cancellationToken); + var id = Guid.NewGuid().ToString(); + var number = RandomNumberGenerator.GetInt32(10, 100).ToString(CultureInfo.InvariantCulture); + var request = new MfaApprovalRequest + { + MfaApprovalRequestId = id, + UserId = requester.UserId, + RequesterKind = (int)requester.Kind, + RequesterId = requester.RequesterId, + ClientApplication = (int)requester.ClientApplication, + InstallationLabel = Limit(label, 256), + SharedMode = requester.SharedMode, + DepartmentId = requester.DepartmentId, + Purpose = (int)requester.Purpose, + Operation = requester.Purpose == MfaApprovalPurpose.StepUp ? requester.Operation : null, + LockVersion = requester.LockVersion, + AuthenticationGeneration = requester.AuthenticationGeneration, + MatchNumberHash = MfaApprovalRequest.HashMatchNumber(id, number), + OriginRegion = Limit(region, 256), + State = (int)MfaApprovalRequestState.Pending, + Version = 1, + MaxAttempts = Math.Max(1, PasskeyConfig.ApprovalMaxNumberAttempts), + CreatedOnUtc = now, + ExpiresOnUtc = now.Add(Lifetime) + }; + + // A concurrent request for the same user won the one pending slot. + if (!await _requests.TryInsertPendingAsync(request, now, cancellationToken)) + return MfaApprovalStart.Of(MfaApprovalOutcome.TooManyRequests); + + await NotifyAsync(requester.UserId, approvers, id); + await AuditAsync(requester.UserId, requester.UserName, requester.AuditSystem, requester.IpAddress, SystemAuditTypes.MfaApprovalRequested, true, + $"Responder approval requested for {MfaApprovalOutcomes.PurposeName(requester.Purpose)}" + + $"{(requester.Operation == null ? "" : $" ({requester.Operation})")} from {PasskeyService.ClientLabel(requester.ClientApplication)}; request {id}.", + cancellationToken); + + return new MfaApprovalStart + { + Outcome = MfaApprovalOutcome.Succeeded, + ApprovalRequestId = id, + MatchNumber = number, + ExpiresInSeconds = (int)Lifetime.TotalSeconds + }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval request could not be created."); + return MfaApprovalStart.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + public async Task GetForRequesterAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, + CancellationToken cancellationToken = default) + { + if (!IsValidId(approvalRequestId) || string.IsNullOrWhiteSpace(requesterId)) + return MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest); + + try + { + var request = await _requests.GetAsync(approvalRequestId, cancellationToken); + return IsRequester(request, requesterKind, requesterId) + ? MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, request) + : MfaApprovalResult.Of(MfaApprovalOutcome.NotFound); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval request could not be read."); + return MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + public async Task CancelAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, + CancellationToken cancellationToken = default) + { + if (!IsValidId(approvalRequestId) || string.IsNullOrWhiteSpace(requesterId)) + return MfaApprovalOutcome.InvalidRequest; + + try + { + return await _requests.TryCancelAsync(approvalRequestId, requesterKind, requesterId, _time.GetUtcNow().UtcDateTime, cancellationToken) + ? MfaApprovalOutcome.Succeeded + : MfaApprovalOutcome.NotFound; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval request could not be canceled."); + return MfaApprovalOutcome.ServiceUnavailable; + } + } + + public async Task ConsumeAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, string userId, + long authenticationGeneration, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + return MfaApprovalResult.Of(MfaApprovalOutcome.Unavailable); + if (!IsValidId(approvalRequestId) || string.IsNullOrWhiteSpace(requesterId)) + return MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest); + + try + { + var request = await _requests.GetAsync(approvalRequestId, cancellationToken); + if (!IsRequester(request, requesterKind, requesterId) || !SameUser(request.UserId, userId) || + request.AuthenticationGeneration != authenticationGeneration) + return MfaApprovalResult.Of(MfaApprovalOutcome.NotFound); + + var now = _time.GetUtcNow().UtcDateTime; + switch (request.EffectiveState(now)) + { + case MfaApprovalRequestState.Pending: + return MfaApprovalResult.Of(MfaApprovalOutcome.Pending, request); + case MfaApprovalRequestState.Denied: + return MfaApprovalResult.Of(MfaApprovalOutcome.Denied, request); + case MfaApprovalRequestState.Approved: + break; + case MfaApprovalRequestState.Consumed: + return MfaApprovalResult.Of(MfaApprovalOutcome.NotFound); + default: + return MfaApprovalResult.Of(MfaApprovalOutcome.Expired, request); + } + + // The approving passkey and Responder session must still count when the approval is used. + var reference = MfaApprovalRequest.FactorReferenceFor(request.ApproverPasskeyId, request.ApproverSessionId); + if (!await ApprovalApprovers.IsValidAsync(_passkeyRows, _sessions, request.UserId, reference, authenticationGeneration, now, cancellationToken)) + return MfaApprovalResult.Of(MfaApprovalOutcome.Expired, request); + + if (!await _requests.TryConsumeAsync(approvalRequestId, requesterKind, requesterId, now, ConsumeGrace, cancellationToken)) + return MfaApprovalResult.Of(MfaApprovalOutcome.Expired, request); + + request.State = (int)MfaApprovalRequestState.Consumed; + request.ConsumedOnUtc = now; + return MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, request); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval could not be used; the verification was refused."); + return MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + public Task IsApproverValidAsync(string userId, string factorReference, long authenticationGeneration, CancellationToken cancellationToken = default) => + ApprovalApprovers.IsValidAsync(_passkeyRows, _sessions, userId, factorReference, authenticationGeneration, _time.GetUtcNow().UtcDateTime, + cancellationToken); + + // ── Approver (Responder) ────────────────────────────────────────────────────── + + public async Task GetPendingForApproverAsync(PasskeyCaller approver, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + return MfaApprovalResult.Of(MfaApprovalOutcome.Unavailable); + if (!IsApproverCaller(approver)) + return MfaApprovalResult.Of(MfaApprovalOutcome.SessionRequired); + + try + { + if (!await HasApprovingPasskeyAsync(approver.UserId, cancellationToken) || !await IsEligibleApproverSessionAsync(approver, cancellationToken)) + return MfaApprovalResult.Of(MfaApprovalOutcome.Unavailable); + + var request = await _requests.GetPendingForUserAsync(approver.UserId, _time.GetUtcNow().UtcDateTime, cancellationToken); + return MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, + request != null && request.AuthenticationGeneration == approver.AuthenticationGeneration ? request : null); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Pending Responder approval requests could not be read."); + return MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + public async Task<(MfaApprovalOutcome Outcome, PasskeyCeremonyStart Ceremony)> BeginApprovalAsync(PasskeyCaller approver, string approvalRequestId, + CancellationToken cancellationToken = default) + { + var open = await OpenForApproverAsync(approver, approvalRequestId, cancellationToken); + if (!open.Succeeded) + return (open.Outcome, null); + + return (MfaApprovalOutcome.Succeeded, + await _passkeys.BeginAssertionAsync(approver.ForApprovalRequest(approvalRequestId), AuthenticationChallengePurpose.ApprovalResponse, cancellationToken)); + } + + public async Task<(MfaApprovalResult Result, PasskeyOutcome Passkey)> ApproveAsync(PasskeyCaller approver, string approvalRequestId, + string matchNumber, string requestId, string credentialJson, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(matchNumber) || matchNumber.Trim().Length != 2 || !matchNumber.Trim().All(char.IsAsciiDigit)) + return (MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest), PasskeyOutcome.Succeeded); + + var open = await OpenForApproverAsync(approver, approvalRequestId, cancellationToken); + if (!open.Succeeded) + return (open, PasskeyOutcome.Succeeded); + + var request = open.Request; + try + { + // The number first: a wrong one counts against the request, and the passkey ceremony stays usable for a retry. + var now = _time.GetUtcNow().UtcDateTime; + if (!CryptographicOperations.FixedTimeEquals(MfaApprovalRequest.HashMatchNumber(approvalRequestId, matchNumber), request.MatchNumberHash)) + { + var state = await _requests.RecordWrongNumberAsync(approvalRequestId, now, cancellationToken); + if (state == MfaApprovalRequestState.Denied) + { + await AuditAsync(approver.UserId, approver.UserName, approver.AuditSystem, approver.IpAddress, SystemAuditTypes.MfaApprovalDenied, false, + $"Responder approval request {approvalRequestId} denied after too many wrong numbers.", cancellationToken); + await NotifyRequesterAsync(request, MfaApprovalRequestState.Denied, cancellationToken); + await RecordDeniedAsync(request, approver.SessionId, cancellationToken); + return (MfaApprovalResult.Of(MfaApprovalOutcome.Denied, request), PasskeyOutcome.Succeeded); + } + + if (state == null) + return (MfaApprovalResult.Of(MfaApprovalOutcome.Expired), PasskeyOutcome.Succeeded); + + request.Attempts++; + return (MfaApprovalResult.Of(MfaApprovalOutcome.NumberMismatch, request), PasskeyOutcome.Succeeded); + } + + var assertion = await _passkeys.CompleteAssertionAsync(approver.ForApprovalRequest(approvalRequestId), + AuthenticationChallengePurpose.ApprovalResponse, requestId, credentialJson, cancellationToken); + if (!assertion.Succeeded) + return (MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest, request), assertion.Outcome); + + if (!await _requests.TryApproveAsync(approvalRequestId, approver.SessionId, assertion.Passkey.UserPasskeyId, _time.GetUtcNow().UtcDateTime, + cancellationToken)) + return (MfaApprovalResult.Of(MfaApprovalOutcome.Expired), PasskeyOutcome.Succeeded); + + await AuditAsync(approver.UserId, approver.UserName, approver.AuditSystem, approver.IpAddress, SystemAuditTypes.MfaApprovalApproved, true, + $"Responder approval request {approvalRequestId} approved with passkey {assertion.Passkey.UserPasskeyId} from session {approver.SessionId}.", + cancellationToken); + request.State = (int)MfaApprovalRequestState.Approved; + await NotifyRequesterAsync(request, MfaApprovalRequestState.Approved, cancellationToken); + return (MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, request), PasskeyOutcome.Succeeded); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval could not be recorded."); + return (MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable), PasskeyOutcome.Succeeded); + } + } + + public async Task DenyAsync(PasskeyCaller approver, string approvalRequestId, MfaApprovalEndReason reason, + CancellationToken cancellationToken = default) + { + if (reason is not (MfaApprovalEndReason.Declined or MfaApprovalEndReason.NotMe)) + return MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest); + + var open = await OpenForApproverAsync(approver, approvalRequestId, cancellationToken); + if (!open.Succeeded) + return open; + + var request = open.Request; + try + { + if (!await _requests.TryDenyAsync(approvalRequestId, reason, _time.GetUtcNow().UtcDateTime, cancellationToken)) + return MfaApprovalResult.Of(MfaApprovalOutcome.Expired); + + // "Not me": the first factor was used by someone else, so that sign-in ends here (plan section 7.9 step 5). + var abandoned = false; + if (reason == MfaApprovalEndReason.NotMe && request.Requester == MfaApprovalRequesterKind.LoginTransaction) + abandoned = await _loginTransactions.TryAbandonAsync(request.RequesterId, cancellationToken); + + await AuditAsync(approver.UserId, approver.UserName, approver.AuditSystem, approver.IpAddress, SystemAuditTypes.MfaApprovalDenied, false, + reason == MfaApprovalEndReason.NotMe + ? $"Responder approval request {approvalRequestId} denied: the user did not request it. " + + (abandoned ? "The sign-in was ended; change the password, because it was used. " : "") + + "Approval requests are suspended." + : $"Responder approval request {approvalRequestId} declined.", + cancellationToken); + + // "Not me" is its own notice (it also pauses requests); a second plain denial in a row pauses them too. + var notice = new SecurityNoticeRequest + { + UserId = request.UserId, + Kind = reason == MfaApprovalEndReason.NotMe ? SecurityNoticeKind.ApprovalNotMe : SecurityNoticeKind.ApprovalSuspended, + ClientApplication = (UserSessionClientApplication)request.ClientApplication, + InstallationLabel = request.InstallationLabel, + Region = request.OriginRegion + }; + if (reason == MfaApprovalEndReason.NotMe) + await _notices.QueueAsync(notice, cancellationToken); + else if (await IsSuspendedAsync(request.UserId, _time.GetUtcNow().UtcDateTime, cancellationToken)) + await _notices.QueueOnceAsync(notice, SuspensionWindow, cancellationToken); + + request.State = (int)MfaApprovalRequestState.Denied; + request.EndReason = (int)reason; + await NotifyRequesterAsync(request, MfaApprovalRequestState.Denied, cancellationToken); + await RecordDeniedAsync(request, approver.SessionId, cancellationToken); + return MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, request); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval could not be denied."); + return MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + // ── Rules ───────────────────────────────────────────────────────────────────── + + /// + /// Two denials or expiries in a row, or one "not me" denial, suspend new requests until the suspension window has + /// passed since the last of them (plan section 7.9 abuse controls). Canceled and superseded requests do not count. + /// + private async Task IsSuspendedAsync(string userId, DateTime utcNow, CancellationToken cancellationToken) + { + var since = utcNow - SuspensionWindow; + var decided = (await _requests.GetRecentForUserAsync(userId, 10, cancellationToken)) + .Where(r => r.EffectiveState(utcNow) is not (MfaApprovalRequestState.Pending or MfaApprovalRequestState.Canceled)) + .OrderByDescending(r => r.CreatedOnUtc) + .ToList(); + + DateTime Ended(MfaApprovalRequest r) => r.DecidedOnUtc ?? r.ExpiresOnUtc; + bool Failed(MfaApprovalRequest r) => r.EffectiveState(utcNow) is MfaApprovalRequestState.Denied or MfaApprovalRequestState.Expired; + + if (decided.Any(r => r.RequestState == MfaApprovalRequestState.Denied && r.EndReason == (int)MfaApprovalEndReason.NotMe && Ended(r) > since)) + return true; + + return decided.Count >= 2 && Failed(decided[0]) && Failed(decided[1]) && Ended(decided[0]) > since; + } + + /// The user's Responder sessions that can approve now, excluding the requester's own session. + /// A denied approval is a denied verification in the requester's recent activity (plan section 6.5). + private Task RecordDeniedAsync(MfaApprovalRequest request, string approverSessionId, CancellationToken cancellationToken) => + _activity.RecordAsync(new MfaActivityEntry + { + UserId = request.UserId, + Method = MfaEvidenceMethod.PasskeyApproval, + Purpose = request.RequestPurpose switch + { + MfaApprovalPurpose.StepUp => MfaEvidencePurpose.StepUp, + MfaApprovalPurpose.Adp => MfaEvidencePurpose.AdpStepUp, + MfaApprovalPurpose.Unlock => MfaEvidencePurpose.SharedUnlock, + _ => MfaEvidencePurpose.Login + }, + Successful = false, + ClientApplication = (UserSessionClientApplication)request.ClientApplication, + InstallationLabel = request.InstallationLabel, + SharedMode = request.SharedMode, + DepartmentId = request.DepartmentId, + SessionId = request.Requester == MfaApprovalRequesterKind.Session ? request.RequesterId : null, + ApproverSessionId = approverSessionId + }, cancellationToken); + + /// + /// Tells a signed-in requester its request was decided, over its own realtime connection, so it need not wait for the + /// next poll (workbook section 7.4). A sign-in in progress has no connection and keeps polling. Best effort: the + /// decision is already committed, and the state is all the event carries. + /// + private async Task NotifyRequesterAsync(MfaApprovalRequest request, MfaApprovalRequestState state, CancellationToken cancellationToken) + { + if (request?.Requester != MfaApprovalRequesterKind.Session) + return; + + try + { + await _sessionEvents.PublishAsync(request.RequesterId, new SessionEventMessage + { + Name = SessionEvents.MfaApprovalChanged, + ApprovalRequestId = request.MfaApprovalRequestId, + State = MfaApprovalOutcomes.StateName(state) + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "An approval decision event could not be sent; the requester keeps polling."); + } + } + + public async Task<(int Installations, int Passkeys)> DisableInstallationsAsync(string userId, string installationId, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default) + { + var now = _time.GetUtcNow().UtcDateTime; + var installations = await _sessions.DisableApprovalsAsync(userId, installationId, now, cancellationToken); + var passkeys = 0; + if (installationId == null) + foreach (var passkey in await _passkeyRows.GetActiveForUserAsync(userId, cancellationToken) ?? Array.Empty()) + if (ApprovalApprovers.IsApprovingPasskey(passkey, userId) && await _passkeyRows.TrySetApprovalEnabledAsync(passkey.UserPasskeyId, userId, false, + cancellationToken)) + passkeys++; + + if (installations + passkeys == 0) + return (0, 0); + + // A request waiting for a stopped installation ends now; approvals it gave stop counting at their next read. + await _requests.CancelPendingForUserAsync(userId, MfaApprovalEndReason.ApproverRevoked, now, cancellationToken); + await AuditAsync(userId, request?.UserName, SystemAuditSystems.Api, request?.IpAddress, SystemAuditTypes.ApprovalInstallationsDisabled, true, + installationId == null + ? $"Approvals stopped on every Responder installation ({installations}) and passkey ({passkeys})." + : $"Approvals stopped on Responder installation {SharedSessionAudit.SessionSuffix(installationId)}.", + cancellationToken); + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = userId, Kind = SecurityNoticeKind.ApprovalTurnedOff, ClientApplication = UserSessionClientApplication.Responder + }, cancellationToken); + return (installations, passkeys); + } + + private async Task> EligibleApproverSessionsAsync(string userId, string excludeSessionId, CancellationToken cancellationToken) + { + var user = await _identityUsers.GetByIdAsync(userId); + if (user == null || !await HasApprovingPasskeyAsync(userId, cancellationToken)) + return new List(); + + var now = _time.GetUtcNow().UtcDateTime; + return (await _sessions.GetActiveByUserAsync(userId, now) ?? Array.Empty()) + .Where(s => ApprovalApprovers.IsEligibleSession(s, userId, user.AuthenticationGeneration, now) && + !string.Equals(s.UserSessionId, excludeSessionId, StringComparison.Ordinal)) + .ToList(); + } + + /// An active Responder passkey with approval on, at Responder's current relying party. + private async Task HasApprovingPasskeyAsync(string userId, CancellationToken cancellationToken) + { + var party = _registry.Get(UserSessionClientApplication.Responder); + return party != null && (await _passkeyRows.GetActiveForUserAsync(userId, cancellationToken) ?? Array.Empty()) + .Any(p => ApprovalApprovers.IsApprovingPasskey(p, userId) && string.Equals(p.RpId, party.RpId, StringComparison.Ordinal)); + } + + /// A pending, unexpired request of the approver's own account under its current generation. + private async Task OpenForApproverAsync(PasskeyCaller approver, string approvalRequestId, CancellationToken cancellationToken) + { + if (!IsEnabled) + return MfaApprovalResult.Of(MfaApprovalOutcome.Unavailable); + if (!IsApproverCaller(approver)) + return MfaApprovalResult.Of(MfaApprovalOutcome.SessionRequired); + if (!IsValidId(approvalRequestId)) + return MfaApprovalResult.Of(MfaApprovalOutcome.InvalidRequest); + + try + { + var request = await _requests.GetAsync(approvalRequestId, cancellationToken); + if (request == null || !SameUser(request.UserId, approver.UserId) || request.AuthenticationGeneration != approver.AuthenticationGeneration) + return MfaApprovalResult.Of(MfaApprovalOutcome.NotFound); + if (!await IsEligibleApproverSessionAsync(approver, cancellationToken)) + return MfaApprovalResult.Of(MfaApprovalOutcome.Unavailable); + + return request.EffectiveState(_time.GetUtcNow().UtcDateTime) switch + { + MfaApprovalRequestState.Pending => MfaApprovalResult.Of(MfaApprovalOutcome.Succeeded, request), + MfaApprovalRequestState.Denied => MfaApprovalResult.Of(MfaApprovalOutcome.Denied, request), + _ => MfaApprovalResult.Of(MfaApprovalOutcome.Expired, request) + }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A Responder approval request could not be read."); + return MfaApprovalResult.Of(MfaApprovalOutcome.ServiceUnavailable); + } + } + + /// + /// The approver's own session record still takes requests: active, personal, current, and not stopped from the account + /// page (plan section 6.5). A stopped installation can neither see, approve nor deny a request. + /// + private async Task IsEligibleApproverSessionAsync(PasskeyCaller approver, CancellationToken cancellationToken) => + ApprovalApprovers.IsEligibleSession(await _sessions.GetByIdAsync(approver.SessionId), approver.UserId, approver.AuthenticationGeneration, + _time.GetUtcNow().UtcDateTime); + + /// An approver is a personal Responder session asking for itself (shared sessions arrive in slice 13). + private static bool IsApproverCaller(PasskeyCaller approver) => + approver != null && !string.IsNullOrWhiteSpace(approver.SessionId) && !string.IsNullOrWhiteSpace(approver.UserId) && + approver.ClientApplication == UserSessionClientApplication.Responder && !approver.SharedMode && + string.IsNullOrWhiteSpace(approver.LoginTransactionId); + + /// + /// What the approver is shown about the requester: its installation label and a coarse origin (region and country, + /// never the city or address). Labels only, so a failed lookup leaves them empty. + /// + private async Task<(string Label, string Region)> RequesterContextAsync(MfaApprovalRequester requester, CancellationToken cancellationToken) + { + try + { + if (requester.Kind == MfaApprovalRequesterKind.Session) + { + var session = await _sessions.GetByIdAsync(requester.RequesterId); + return (requester.InstallationLabel ?? session?.DeviceName, Coarse(session?.LastRegion, session?.LastCountry)); + } + + var location = string.IsNullOrWhiteSpace(requester.IpAddress) + ? null + : await _location.GetApproximateLocationAsync(requester.IpAddress, cancellationToken); + return (requester.InstallationLabel, Coarse(location?.Region, location?.Country)); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Requester context for a Responder approval could not be read."); + return (requester.InstallationLabel, null); + } + } + + private static string Coarse(string region, string country) => + string.Join(", ", new[] { region, country }.Where(part => !string.IsNullOrWhiteSpace(part)).Select(part => part.Trim())) is { Length: > 0 } text + ? text + : null; + + /// + /// A generic push to the user's Responder, once per department it signs in to, through the Responder-only Novu + /// subscriber (workbook section 1). It names no department, app or number. Responder also shows pending requests when + /// opened, so a lost push never blocks the user, and a push failure never fails the request. + /// + private async Task NotifyAsync(string userId, IEnumerable approvers, string approvalRequestId) + { + try + { + var codes = new HashSet(StringComparer.Ordinal); + foreach (var departmentId in approvers.Select(s => s.DepartmentId).Distinct()) + { + var department = departmentId is int id + ? await _departments.GetDepartmentByIdAsync(id) + : await _departments.GetDepartmentByUserIdAsync(userId); + if (!string.IsNullOrWhiteSpace(department?.Code)) + codes.Add(department.Code); + } + + foreach (var code in codes) + await _novu.SendUserNotification(PushTitle, PushBody, userId, code, PushEventPrefix + approvalRequestId, + ((int)PushSoundTypes.Notifiation).ToString(CultureInfo.InvariantCulture)); + } + catch (Exception ex) + { + Logging.LogException(ex, "The Responder approval push could not be sent; Responder shows the request when opened."); + } + } + + private static bool IsRequester(MfaApprovalRequest request, MfaApprovalRequesterKind kind, string requesterId) => + request != null && request.RequesterKind == (int)kind && string.Equals(request.RequesterId, requesterId, StringComparison.Ordinal); + + private static bool IsValidId(string id) => !string.IsNullOrWhiteSpace(id) && id.Length <= 64; + + private static bool SameUser(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + private static string Limit(string value, int length) => + string.IsNullOrWhiteSpace(value) ? null : value.Length <= length ? value : value[..length]; + + private async Task AuditAsync(string userId, string userName, SystemAuditSystems system, string ipAddress, SystemAuditTypes type, bool successful, + string data, CancellationToken cancellationToken) + { + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)system, + Type = (int)type, + UserId = userId, + Username = userName, + Successful = successful, + IpAddress = ipAddress, + ServerName = Environment.MachineName, + Data = data + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, $"Responder approval audit ({type}) could not be saved."); + } + } + } +} diff --git a/Core/Resgrid.Services/MfaCredentialStateService.cs b/Core/Resgrid.Services/MfaCredentialStateService.cs new file mode 100644 index 000000000..f735d7f1e --- /dev/null +++ b/Core/Resgrid.Services/MfaCredentialStateService.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class MfaCredentialStateService : IMfaCredentialStateService + { + private readonly IUserPasskeyRepository _passkeys; + private readonly IUserSessionsRepository _sessions; + private readonly IDepartmentSsoConfigRepository _ssoConfigs; + private readonly TimeProvider _time; + + public MfaCredentialStateService(IUserPasskeyRepository passkeys, IUserSessionsRepository sessions, IDepartmentSsoConfigRepository ssoConfigs, + TimeProvider time) + { + _passkeys = passkeys; + _sessions = sessions; + _ssoConfigs = ssoConfigs; + _time = time; + } + + public async Task ResolveAsync(string userId, int departmentId, UserSessionClientApplication client, + MfaEvidenceMethod method, string factorReference, long authenticationGeneration, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(factorReference)) + return null; + + switch (method) + { + case MfaEvidenceMethod.Passkey: + { + // A passkey grant is only ever for the client the passkey is bound to (plan section 3 item 14). + var passkey = await PasskeyForReferenceAsync(factorReference, cancellationToken); + return passkey != null && Owns(passkey, userId) && passkey.IsActive && passkey.ClientApplication == (int)client + ? new MfaCredentialSnapshot { CredentialId = factorReference, StateVersion = passkey.StateVersion } + : null; + } + case MfaEvidenceMethod.PasskeyApproval: + { + // The approving Responder passkey (approval still on) and its personal Responder session must both still + // count; the passkey's state version moves with any change to either credential. + if (!MfaApprovalRequest.TryParseFactorReference(factorReference, out var passkeyId, out _) || + !await ApprovalApprovers.IsValidAsync(_passkeys, _sessions, userId, factorReference, authenticationGeneration, Now, cancellationToken)) + return null; + + var passkey = await _passkeys.GetAsync(passkeyId, cancellationToken); + return passkey == null ? null : new MfaCredentialSnapshot { CredentialId = factorReference, StateVersion = passkey.StateVersion }; + } + case MfaEvidenceMethod.Federated: + { + // The department's tested provider step-up mapping, at the version the round trip was checked against. + var config = await TestedConfigAsync(departmentId); + return config != null && string.Equals(factorReference, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion), StringComparison.Ordinal) + ? new MfaCredentialSnapshot { CredentialId = factorReference, StateVersion = config.FederatedMfaMappingVersion } + : null; + } + default: + return null; + } + } + + public async Task IsCurrentAsync(ProtectedDataGrant grant, CancellationToken cancellationToken = default) + { + if (grant == null || grant.Version < 2 || grant.AuthenticationGeneration == null || grant.MfaStateVersion == null) + return false; + + var method = grant.MfaMethod switch + { + ProtectedDataGrantMfaMethods.Passkey => MfaEvidenceMethod.Passkey, + ProtectedDataGrantMfaMethods.PasskeyApproval => MfaEvidenceMethod.PasskeyApproval, + ProtectedDataGrantMfaMethods.Federated => MfaEvidenceMethod.Federated, + _ => (MfaEvidenceMethod?)null + }; + if (method == null) + return false; + + var current = await ResolveAsync(grant.UserId, grant.DepartmentId, (UserSessionClientApplication)grant.ClientApp, method.Value, + grant.MfaCredentialId, grant.AuthenticationGeneration.Value, cancellationToken); + return current != null && current.StateVersion == grant.MfaStateVersion.Value && + string.Equals(current.CredentialId, grant.MfaCredentialId, StringComparison.Ordinal); + } + + private DateTime Now => _time.GetUtcNow().UtcDateTime; + + private static bool Owns(UserPasskey passkey, string userId) => string.Equals(passkey.UserId, userId, StringComparison.OrdinalIgnoreCase); + + private Task PasskeyForReferenceAsync(string factorReference, CancellationToken cancellationToken) + { + const string prefix = "passkey:"; + if (!factorReference.StartsWith(prefix, StringComparison.Ordinal) || factorReference.Length <= prefix.Length) + return Task.FromResult(null); + + return _passkeys.GetAsync(factorReference.Substring(prefix.Length), cancellationToken); + } + + private async Task TestedConfigAsync(int departmentId) + { + if (departmentId <= 0) + return null; + + var config = (await _ssoConfigs.GetAllByDepartmentIdAsync(departmentId))?.FirstOrDefault(c => c.IsEnabled); + return FederatedMfaMapping.IsTested(config) ? config : null; + } + } +} diff --git a/Core/Resgrid.Services/MfaEvidenceService.cs b/Core/Resgrid.Services/MfaEvidenceService.cs new file mode 100644 index 000000000..f2f69e732 --- /dev/null +++ b/Core/Resgrid.Services/MfaEvidenceService.cs @@ -0,0 +1,184 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class MfaEvidenceService : IMfaEvidenceService + { + private readonly IUserSessionMfaEvidenceRepository _evidence; + private readonly IUserMfaStateRepository _mfaState; + private readonly IUserPasskeyRepository _passkeys; + private readonly IUserSessionsRepository _sessions; + private readonly IMfaActivityRepository _activity; + private readonly TimeProvider _time; + + public MfaEvidenceService(IUserSessionMfaEvidenceRepository evidence, IUserMfaStateRepository mfaState, IUserPasskeyRepository passkeys, + IUserSessionsRepository sessions, IMfaActivityRepository activity, TimeProvider time) + { + _activity = activity; + _evidence = evidence; + _mfaState = mfaState; + _passkeys = passkeys; + _sessions = sessions; + _time = time; + } + + public async Task RecordAsync(string userId, string sessionKey, UserSessionClientApplication client, MfaEvidenceKind kind, + MfaEvidenceMethod method, MfaEvidencePurpose purpose, DateTime verifiedOnUtc, long authenticationGeneration, + int? departmentId = null, string factorReference = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) + throw new ArgumentException("A user id is required.", nameof(userId)); + if (string.IsNullOrWhiteSpace(sessionKey)) + throw new ArgumentException("Evidence must belong to a session.", nameof(sessionKey)); + + // A factor used to recover an account is recorded, but only ever as recovery evidence (plan section 6.1). + if (method == MfaEvidenceMethod.RecoveryCode && kind != MfaEvidenceKind.Recovery) + throw new ArgumentException("Recovery-code use can only be recorded as recovery evidence.", nameof(kind)); + + var retention = TimeSpan.FromHours(Math.Max(1, TwoFactorConfig.MfaEvidenceRetentionHours)); + await _evidence.InsertAsync(new MfaEvidence + { + MfaEvidenceId = Guid.NewGuid().ToString(), + UserId = userId, + SessionKey = sessionKey, + ClientApplication = (int)client, + Kind = (int)kind, + Method = (int)method, + Purpose = (int)purpose, + DepartmentId = departmentId, + VerifiedOnUtc = verifiedOnUtc, + ExpiresOnUtc = verifiedOnUtc.Add(retention), + AuthenticationGeneration = authenticationGeneration, + FactorReference = factorReference + }, cancellationToken); + + // Every verified second factor or recovery code is the account's recent activity (plan section 6.5). History only, + // so a failure here never fails the verification it records. + if (kind is MfaEvidenceKind.SecondFactor or MfaEvidenceKind.Recovery) + { + try + { + var sessionId = MfaEvidence.TrackedSessionId(sessionKey); + MfaApprovalRequest.TryParseFactorReference(method == MfaEvidenceMethod.PasskeyApproval ? factorReference : null, out _, out var approverSessionId); + await _activity.InsertAsync(MfaActivityRecords.Create(new MfaActivityEntry + { + UserId = userId, Method = method, Purpose = purpose, Successful = true, ClientApplication = client, DepartmentId = departmentId, + SessionId = sessionId, ApproverSessionId = approverSessionId + }, sessionId == null ? null : await _sessions.GetByIdAsync(sessionId), verifiedOnUtc), cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "MFA activity could not be recorded."); + } + } + + // The last successful second factor becomes the user's default choice on any installation (plan section 7.5 + // rule 5). A display default only, so a failure here never fails the verification it follows. + if (kind == MfaEvidenceKind.SecondFactor) + { + try + { + await _mfaState.SetPreferredMethodAsync(userId, (int)method, _time.GetUtcNow().UtcDateTime, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "MFA method preference update failed."); + } + } + } + + public async Task GetLatestFirstFactorAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default) + => await CountsForSessionAsync(await GetLatestAsync(userId, sessionKey, MfaEvidenceKind.FirstFactor, currentGeneration, cancellationToken)); + + public async Task HasFreshFirstFactorAsync(string userId, string sessionKey, long currentGeneration, TimeSpan maxAge, + DateTime utcNow, CancellationToken cancellationToken = default) + { + var latest = await GetLatestFirstFactorAsync(userId, sessionKey, currentGeneration, cancellationToken); + return IsFresh(latest, maxAge, utcNow); + } + + public async Task GetLatestSecondFactorAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default) + => await StillCountsAsync(await CountsForSessionAsync( + await GetLatestAsync(userId, sessionKey, MfaEvidenceKind.SecondFactor, currentGeneration, cancellationToken)), cancellationToken); + + public async Task GetLatestStepUpAsync(string userId, string sessionKey, long currentGeneration, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(sessionKey)) + return null; + + return await StillCountsAsync(await CountsForSessionAsync(await _evidence.GetLatestForPurposeAsync(userId, sessionKey, + MfaEvidenceKind.SecondFactor, MfaEvidencePurpose.StepUp, currentGeneration, _time.GetUtcNow().UtcDateTime, cancellationToken)), + cancellationToken); + } + + /// + /// On a shared session nothing counts while it is locked, and nothing verified before its last lock counts after an + /// unlock (plan section 12.5.3), however the lock happened. The latest evidence is the only candidate, so when it is + /// from before the lock there is none. + /// + private async Task CountsForSessionAsync(MfaEvidence evidence) + { + var sessionId = MfaEvidence.TrackedSessionId(evidence?.SessionKey); + if (sessionId == null) + return evidence; + + return SharedSessionRules.EvidenceCounts(await _sessions.GetByIdAsync(sessionId), evidence.VerifiedOnUtc) ? evidence : null; + } + + /// + /// A Responder approval counts only while its passkey and Responder session do (plan section 7.9 revocation); when + /// the latest evidence is an approval that no longer counts, there is no evidence and the user verifies again. + /// + private async Task StillCountsAsync(MfaEvidence evidence, CancellationToken cancellationToken) + { + if (evidence?.Method != (int)MfaEvidenceMethod.PasskeyApproval) + return evidence; + + return await ApprovalApprovers.IsValidAsync(_passkeys, _sessions, evidence.UserId, evidence.FactorReference, evidence.AuthenticationGeneration, + _time.GetUtcNow().UtcDateTime, cancellationToken) + ? evidence + : null; + } + + public Task RevokeForUserAsync(string userId, CancellationToken cancellationToken = default) + => _evidence.RevokeForUserAsync(userId, _time.GetUtcNow().UtcDateTime, cancellationToken); + + public Task RevokeForFactorAsync(string userId, string factorReference, CancellationToken cancellationToken = default) + => string.IsNullOrWhiteSpace(factorReference) + ? Task.CompletedTask + : _evidence.RevokeForFactorAsync(userId, factorReference, _time.GetUtcNow().UtcDateTime, cancellationToken); + + /// + /// Fresh means verified within and not in the future beyond a small clock skew. A future + /// timestamp is never treated as fresh evidence. + /// + public static bool IsFresh(MfaEvidence evidence, TimeSpan maxAge, DateTime utcNow) + { + if (evidence == null) + return false; + + var age = utcNow - evidence.VerifiedOnUtc; + return age >= TimeSpan.FromSeconds(-30) && age <= maxAge; + } + + private Task GetLatestAsync(string userId, string sessionKey, MfaEvidenceKind kind, long currentGeneration, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(sessionKey)) + return Task.FromResult(null); + + return _evidence.GetLatestAsync(userId, sessionKey, kind, currentGeneration, _time.GetUtcNow().UtcDateTime, cancellationToken); + } + } +} diff --git a/Core/Resgrid.Services/MfaLoginTransactionService.cs b/Core/Resgrid.Services/MfaLoginTransactionService.cs new file mode 100644 index 000000000..304b0cc14 --- /dev/null +++ b/Core/Resgrid.Services/MfaLoginTransactionService.cs @@ -0,0 +1,335 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class MfaLoginTransactionService : IMfaLoginTransactionService + { + /// A secret or completion code is 32 random bytes in base64url; anything much longer is not one. + private const int MaxSecretLength = 128; + + private readonly IMfaLoginTransactionRepository _transactions; + private readonly IMfaPolicyService _policy; + private readonly IDepartmentSsoService _departmentSso; + private readonly IIdentityUserRepository _identityUsers; + private readonly IPasskeyService _passkeys; + private readonly IMfaApprovalService _approvals; + private readonly IPasskeyFeatureGates _gates; + private readonly TimeProvider _time; + + public MfaLoginTransactionService(IMfaLoginTransactionRepository transactions, IMfaPolicyService policy, IDepartmentSsoService departmentSso, + IIdentityUserRepository identityUsers, IPasskeyService passkeys, IMfaApprovalService approvals, IPasskeyFeatureGates gates, TimeProvider time) + { + _approvals = approvals; + _gates = gates; + _transactions = transactions; + _policy = policy; + _departmentSso = departmentSso; + _identityUsers = identityUsers; + _passkeys = passkeys; + _time = time; + } + + public bool IsEnabled => TwoFactorConfig.LoginMfaTransactionEnabled; + + private static TimeSpan Lifetime => TimeSpan.FromSeconds(Math.Max(30, TwoFactorConfig.LoginMfaTransactionLifetimeSeconds)); + private static TimeSpan CompletionLifetime => TimeSpan.FromSeconds(Math.Max(10, TwoFactorConfig.LoginMfaCompletionCodeLifetimeSeconds)); + + public async Task BeginAsync(MfaLoginTransactionRequest request, CancellationToken cancellationToken = default) + { + var (start, _) = await InsertAsync(request, cancellationToken); + return start; + } + + public Task BeginCompletedAsync(MfaLoginTransactionRequest request, CancellationToken cancellationToken = default) => + BeginCompletedAsync(request, null, null, null, cancellationToken); + + public Task BeginCompletedAsync(MfaLoginTransactionRequest request, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, CancellationToken cancellationToken = default) => + BeginCompletedAsync(request, (MfaEvidenceMethod?)method, factorReference, (DateTime?)verifiedOnUtc, cancellationToken); + + private async Task BeginCompletedAsync(MfaLoginTransactionRequest request, MfaEvidenceMethod? method, string factorReference, + DateTime? verifiedOnUtc, CancellationToken cancellationToken) + { + var (start, transaction) = await InsertAsync(request, cancellationToken); + var completion = await CompleteAsync(transaction, method, factorReference, verifiedOnUtc, cancellationToken); + return completion.Succeeded + ? new MfaLoginCompletion + { + Outcome = completion.Outcome, + Transaction = start.Secret, + CompletionCode = completion.CompletionCode, + ExpiresInSeconds = completion.ExpiresInSeconds + } + : completion; + } + + private async Task<(MfaLoginTransactionStart Start, MfaLoginTransaction Transaction)> InsertAsync(MfaLoginTransactionRequest request, + CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.UserId)) + throw new ArgumentException("A login transaction needs the user whose first factor was verified.", nameof(request)); + if (request.FirstFactorMethod != MfaEvidenceMethod.Password && request.FirstFactorMethod != MfaEvidenceMethod.Sso) + throw new ArgumentException("A login transaction starts from a password or SSO first factor.", nameof(request)); + + // A passkey counts as enrolled only when one is bound to the app signing in (plan section 3 item 14). + var passkeyEnrolled = await _passkeys.HasActiveForClientAsync(request.UserId, request.ClientApplication, cancellationToken); + var federatedEnrolled = request.DepartmentId is > 0 && + await _departmentSso.IsFederatedMfaAvailableAsync(request.DepartmentId.Value, request.UserId, cancellationToken); + var approvalEnrolled = await _approvals.IsAvailableAsync(request.UserId, request.ClientApplication, cancellationToken); + var choice = await _policy.GetMethodChoiceAsync(request.UserId, request.TotpEnrolled, request.DepartmentId, MfaMethodScope.Login, + passkeyEnrolled, federatedEnrolled, approvalEnrolled, cancellationToken); + + var now = _time.GetUtcNow().UtcDateTime; + var secret = NewSecret(); + var departmentPolicy = await DepartmentPolicyAsync(request.DepartmentId, cancellationToken); + var transaction = new MfaLoginTransaction + { + MfaLoginTransactionId = Guid.NewGuid().ToString(), + SecretHash = Hash(secret), + UserId = request.UserId, + DepartmentId = request.DepartmentId, + ClientApplication = (int)request.ClientApplication, + ClientId = Limit(request.ClientId, 128), + FirstFactorMethod = (int)request.FirstFactorMethod, + FirstFactorVerifiedOnUtc = request.FirstFactorVerifiedOnUtc, + DepartmentSsoConfigId = Limit(request.DepartmentSsoConfigId, 128), + AuthenticationGeneration = request.AuthenticationGeneration, + MfaPolicyVersion = departmentPolicy?.MfaPolicyVersion ?? 0, + Scopes = Limit(string.Join(" ", (request.Scopes ?? Array.Empty()).Distinct(StringComparer.Ordinal)), 512), + // What the member's Responder is shown if asked to approve: the session's own rule, decided now. + SharedMode = SharedSessionRules.SourceFor(departmentPolicy, request.ClientApplication, request.SharedModeRequested, + _gates.SharedDeviceModeEnabled) != SharedModeSource.None, + InstallationLabel = Limit(request.InstallationLabel?.Trim(), 256), + CreatedOnUtc = now, + ExpiresOnUtc = now.Add(Lifetime), + MaxAttempts = Math.Max(1, TwoFactorConfig.LoginMfaTransactionMaxAttempts), + State = (int)MfaLoginTransactionState.Pending + }; + await _transactions.InsertAsync(transaction, cancellationToken); + + return (new MfaLoginTransactionStart { Secret = secret, ExpiresInSeconds = (int)Lifetime.TotalSeconds, Choice = choice }, transaction); + } + + public async Task OpenAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken = default) + { + var lookup = await LookupAsync(secret, client, cancellationToken); + if (lookup.Transaction == null) + return lookup; + + var transaction = lookup.Transaction; + var now = _time.GetUtcNow().UtcDateTime; + var outcome = transaction.TransactionState switch + { + MfaLoginTransactionState.Pending when transaction.ExpiresOnUtc <= now => MfaLoginTransactionOutcome.Expired, + MfaLoginTransactionState.Pending when transaction.Attempts >= transaction.MaxAttempts => MfaLoginTransactionOutcome.TooManyAttempts, + MfaLoginTransactionState.Pending => MfaLoginTransactionOutcome.Usable, + MfaLoginTransactionState.Exhausted => MfaLoginTransactionOutcome.TooManyAttempts, + _ => MfaLoginTransactionOutcome.AlreadyUsed + }; + if (outcome != MfaLoginTransactionOutcome.Usable) + return MfaLoginTransactionResult.Of(outcome); + + return await CheckCurrentAsync(transaction, cancellationToken); + } + + public async Task IsMethodAcceptedAsync(MfaLoginTransaction transaction, MfaEvidenceMethod method, CancellationToken cancellationToken = default) + { + // A recovery code always completes a login, as recovery (plan section 7.6 row 16); it never counts as MFA later. + if (method == MfaEvidenceMethod.RecoveryCode) + return true; + if (method != MfaEvidenceMethod.Totp && method != MfaEvidenceMethod.Passkey && method != MfaEvidenceMethod.Federated && + method != MfaEvidenceMethod.PasskeyApproval) + return false; + + return await _policy.IsMethodAcceptedAsync(transaction.DepartmentId, MfaMethodScope.Login, method, cancellationToken); + } + + public async Task RecordFailedAttemptAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default) + { + try + { + await _transactions.RecordFailedAttemptAsync(transaction.MfaLoginTransactionId, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The failure is already refused and counted against the account lockout; log the missed count loudly. + Logging.LogException(ex, "A failed login MFA attempt could not be counted against its transaction."); + } + } + + public Task CompleteAsync(MfaLoginTransaction transaction, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, CancellationToken cancellationToken = default) => + CompleteAsync(transaction, (MfaEvidenceMethod?)method, factorReference, (DateTime?)verifiedOnUtc, cancellationToken); + + private async Task CompleteAsync(MfaLoginTransaction transaction, MfaEvidenceMethod? method, string factorReference, + DateTime? verifiedOnUtc, CancellationToken cancellationToken) + { + var now = _time.GetUtcNow().UtcDateTime; + var code = NewSecret(); + var completed = await _transactions.TryCompleteAsync(transaction.MfaLoginTransactionId, (int?)method, Limit(factorReference, 256), + verifiedOnUtc, method == MfaEvidenceMethod.RecoveryCode, Hash(code), now.Add(CompletionLifetime), now, cancellationToken); + + // Exactly one completion per transaction: a concurrent winner, an expiry or exhaustion leaves nothing to issue. + return completed + ? new MfaLoginCompletion { Outcome = MfaLoginTransactionOutcome.Usable, CompletionCode = code, ExpiresInSeconds = (int)CompletionLifetime.TotalSeconds } + : new MfaLoginCompletion { Outcome = MfaLoginTransactionOutcome.AlreadyUsed }; + } + + public Task AbandonAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default) => + _transactions.TryAbandonAsync(transaction.MfaLoginTransactionId, cancellationToken); + + public async Task RedeemAsync(string secret, string completionCode, UserSessionClientApplication client, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(completionCode) || completionCode.Length > MaxSecretLength) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Invalid); + + var lookup = await LookupAsync(secret, client, cancellationToken); + if (lookup.Transaction == null) + return lookup; + + var transaction = lookup.Transaction; + var now = _time.GetUtcNow().UtcDateTime; + switch (transaction.TransactionState) + { + case MfaLoginTransactionState.Completed when transaction.CompletionExpiresOnUtc <= now: + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Expired); + case MfaLoginTransactionState.Completed: + break; + case MfaLoginTransactionState.Pending: + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Invalid); + default: + // A lost token response is recovered by a new login; a completion is never issued twice (workbook 7.1). + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.AlreadyUsed); + } + + // The account and department are rechecked before the code is spent: a password change, revocation or policy + // change since the first factor voids the login (plan section 5.2). + var current = await CheckCurrentAsync(transaction, cancellationToken); + if (!current.IsUsable) + return current; + + // Provider MFA counts only under the tested mapping it was verified with; a mapping change since voids it. + if (transaction.CompletionMethod == (int)MfaEvidenceMethod.Federated && !await FederatedMappingCurrentAsync(transaction, cancellationToken)) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.PolicyChanged); + + // A Responder approval counts only while its passkey and Responder session do (plan section 7.9 revocation). + if (transaction.CompletionMethod == (int)MfaEvidenceMethod.PasskeyApproval && !await ApproverStillValidAsync(transaction, cancellationToken)) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.SessionRevoked); + + try + { + if (!await _transactions.TryRedeemAsync(transaction.MfaLoginTransactionId, Hash(completionCode), now, cancellationToken)) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Invalid); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A login MFA completion could not be redeemed; the sign-in was refused."); + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Unavailable); + } + + transaction.State = (int)MfaLoginTransactionState.Redeemed; + transaction.RedeemedOnUtc = now; + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Usable, transaction); + } + + /// The transaction for a secret, bound to the client that started it; nothing about it leaks on a mismatch. + private async Task LookupAsync(string secret, UserSessionClientApplication client, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(secret) || secret.Length > MaxSecretLength) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Invalid); + + MfaLoginTransaction transaction; + try + { + transaction = await _transactions.GetBySecretHashAsync(Hash(secret), cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // Authoritative state is unavailable: the login is refused, never treated as complete (plan section 3 item 4). + Logging.LogException(ex, "Login MFA transaction lookup failed; the sign-in was refused."); + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Unavailable); + } + + return transaction == null || transaction.ClientApplication != (int)client + ? MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Invalid) + : MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Usable, transaction); + } + + private async Task CheckCurrentAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken) + { + try + { + var user = await _identityUsers.GetByIdAsync(transaction.UserId); + if (user == null || user.AuthenticationGeneration != transaction.AuthenticationGeneration) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.SessionRevoked); + + if (await PolicyVersionAsync(transaction.DepartmentId, cancellationToken) != transaction.MfaPolicyVersion) + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.PolicyChanged); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Login MFA transaction state could not be rechecked; the sign-in was refused."); + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Unavailable); + } + + return MfaLoginTransactionResult.Of(MfaLoginTransactionOutcome.Usable, transaction); + } + + private async Task ApproverStillValidAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken) + { + try + { + return await _approvals.IsApproverValidAsync(transaction.UserId, transaction.CompletionFactorReference, transaction.AuthenticationGeneration, + cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "The approving Responder could not be rechecked; the sign-in was refused."); + return false; + } + } + + private async Task FederatedMappingCurrentAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken) + { + try + { + var config = transaction.DepartmentId is > 0 + ? await _departmentSso.GetTestedFederatedMfaConfigAsync(transaction.DepartmentId.Value, cancellationToken) + : null; + return config != null && string.Equals(transaction.CompletionFactorReference, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion), StringComparison.Ordinal); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "The provider step-up mapping could not be rechecked; the sign-in was refused."); + return false; + } + } + + private async Task PolicyVersionAsync(int? departmentId, CancellationToken cancellationToken) => + (await DepartmentPolicyAsync(departmentId, cancellationToken))?.MfaPolicyVersion ?? 0; + + private async Task DepartmentPolicyAsync(int? departmentId, CancellationToken cancellationToken) => + departmentId is > 0 ? await _departmentSso.GetSecurityPolicyForDepartmentAsync(departmentId.Value, cancellationToken) : null; + + private static string NewSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Hash(string secret) => SHA256.HashData(Encoding.UTF8.GetBytes(secret)); + + private static string Limit(string value, int length) => + string.IsNullOrWhiteSpace(value) ? null : value.Length <= length ? value : value[..length]; + } +} diff --git a/Core/Resgrid.Services/MfaPolicyService.cs b/Core/Resgrid.Services/MfaPolicyService.cs new file mode 100644 index 000000000..b16c8fac4 --- /dev/null +++ b/Core/Resgrid.Services/MfaPolicyService.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class MfaPolicyService : IMfaPolicyService + { + private readonly IDepartmentSsoService _departmentSso; + private readonly IUserMfaStateRepository _mfaState; + private readonly IPasskeyFeatureGates _gates; + + public MfaPolicyService(IDepartmentSsoService departmentSso, IUserMfaStateRepository mfaState, IPasskeyFeatureGates gates) + { + _departmentSso = departmentSso; + _mfaState = mfaState; + _gates = gates; + } + + public async Task IsRequireMfaEnforcedAsync(int? departmentId, CancellationToken cancellationToken = default) => + TwoFactorConfig.RequireMfaEnforcementEnabled && await DepartmentRequiresMfaAsync(departmentId, cancellationToken); + + public async Task DepartmentRequiresMfaAsync(int? departmentId, CancellationToken cancellationToken = default) => + (await PolicyForAsync(departmentId, cancellationToken)).RequireMfa; + + public async Task GetMethodChoiceAsync(string userId, bool totpEnrolled, int? departmentId, MfaMethodScope scope, bool passkeyEnrolled = false, + bool federatedEnrolled = false, bool approvalEnrolled = false, CancellationToken cancellationToken = default) + { + // TOTP, a passkey bound to the calling client, Responder approval (an eligible Responder of the user's), and + // provider step-up (a tested mapping and an SSO-linked account); the caller says which the user has. The allowed + // list follows the department's switches. + var enrolled = new List(); + if (totpEnrolled) + enrolled.Add(MfaMethodNames.Totp); + if (passkeyEnrolled) + enrolled.Add(MfaMethodNames.Passkey); + if (approvalEnrolled) + enrolled.Add(MfaMethodNames.PasskeyApproval); + if (federatedEnrolled) + enrolled.Add(MfaMethodNames.Federated); + var allowed = AllowedMethods(await PolicyForAsync(departmentId, cancellationToken), scope, _gates); + var usable = allowed.Where(enrolled.Contains).ToList(); + + string stored = null; + if (usable.Count > 1 && !string.IsNullOrWhiteSpace(userId)) + { + try + { + var method = await _mfaState.GetPreferredMethodAsync(userId, cancellationToken); + stored = method == null ? null : MfaMethodNames.From((MfaEvidenceMethod)method.Value); + } + catch (Exception ex) + { + // A display default only: without it the first usable method is shown. + Logging.LogException(ex, "MFA method preference lookup failed."); + } + } + + return new MfaMethodChoice + { + EnrolledMethods = enrolled, + AllowedMethods = allowed, + Preferred = stored != null && usable.Contains(stored) ? stored : usable.FirstOrDefault() + }; + } + + public async Task IsMethodAcceptedAsync(int? departmentId, MfaMethodScope scope, MfaEvidenceMethod method, + CancellationToken cancellationToken = default) + { + if (method == MfaEvidenceMethod.Totp) + return true; + + var name = MfaMethodNames.From(method); + if (name == null || !AllowedMethods(await PolicyForAsync(departmentId, cancellationToken), scope, _gates).Contains(name)) + return false; + + // Provider step-up counts only while the department's mapping stays enabled and tested (plan section 7.8); a + // mapping change also revokes what the old version verified. + return method != MfaEvidenceMethod.Federated || + (departmentId is > 0 && await _departmentSso.GetTestedFederatedMfaConfigAsync(departmentId.Value, cancellationToken) != null); + } + + public async Task IsEvidenceAcceptedAsync(int? departmentId, MfaMethodScope scope, MfaEvidence evidence, + CancellationToken cancellationToken = default) + { + if (evidence == null || !await IsMethodAcceptedAsync(departmentId, scope, (MfaEvidenceMethod)evidence.Method, cancellationToken)) + return false; + + return scope != MfaMethodScope.Adp || evidence.Purpose != (int)MfaEvidencePurpose.SharedUnlock || + (await PolicyForAsync(departmentId, cancellationToken)).AcceptRecentUnlockMfaForAdp; + } + + /// + /// The second factors a department accepts for a scope (plan sections 7.6 and 10.1). TOTP always. A passkey where + /// the deployment accepts passkeys and the scope's passkey switch is on. Responder approval where approval is + /// deployed and allowed and the row's passkey switch is on, never for security changes or account factors. + /// Provider step-up where deployed and the scope's federated switch is on, never for account factors. + /// + public static IReadOnlyList AllowedMethods(DepartmentSecurityPolicy policy, MfaMethodScope scope, IPasskeyFeatureGates gates) + { + policy ??= new DepartmentSecurityPolicy(); + var allowed = new List { MfaMethodNames.Totp }; + + var passkey = scope switch + { + MfaMethodScope.Login or MfaMethodScope.SecurityChange => gates.LoginAcceptanceEnabled && policy.AllowPasskeysForLoginMfa, + MfaMethodScope.Adp => gates.AdpAcceptanceEnabled && policy.AllowPasskeysForAdp, + MfaMethodScope.Account => gates.LoginAcceptanceEnabled, + _ => false + }; + if (passkey) + allowed.Add(MfaMethodNames.Passkey); + + if (passkey && gates.ResponderApprovalEnabled && policy.AllowResponderApproval && + (scope == MfaMethodScope.Login || scope == MfaMethodScope.Adp)) + allowed.Add(MfaMethodNames.PasskeyApproval); + + var federated = scope switch + { + MfaMethodScope.Login or MfaMethodScope.SecurityChange => policy.AllowFederatedMfaForLoginMfa, + MfaMethodScope.Adp => policy.AllowFederatedMfaForAdp, + _ => false + }; + if (federated && gates.ProviderStepUpEnabled) + allowed.Add(MfaMethodNames.Federated); + + return allowed; + } + + /// + /// The department's policy, or the defaults when it has none (a department with no row is unaffected). A failed + /// read is thrown, never mistaken for the permissive defaults. + /// + private async Task PolicyForAsync(int? departmentId, CancellationToken cancellationToken) + { + if (departmentId is not > 0) + return new DepartmentSecurityPolicy(); + + return await _departmentSso.GetSecurityPolicyForDepartmentAsync(departmentId.Value, cancellationToken) + ?? new DepartmentSecurityPolicy { DepartmentId = departmentId.Value }; + } + } +} diff --git a/Core/Resgrid.Services/ModerationService.cs b/Core/Resgrid.Services/ModerationService.cs index 32c662791..d7109acb7 100644 --- a/Core/Resgrid.Services/ModerationService.cs +++ b/Core/Resgrid.Services/ModerationService.cs @@ -46,6 +46,7 @@ public class ModerationService : IModerationService private readonly IUnitOfWork _unitOfWork; private readonly IOutboundQueueProvider _outboundQueueProvider; private readonly Lazy _protectedWriteService; + private readonly Lazy _searchProjections; public ModerationService(IModerationRequestRepository moderationRequestRepository, IModerationReportRepository moderationReportRepository, IModerationActionRepository moderationActionRepository, @@ -56,7 +57,8 @@ public ModerationService(IModerationRequestRepository moderationRequestRepositor ICallsService callsService, IDepartmentGroupsService departmentGroupsService, IAuthorizationService authorizationService, IAuditService auditService, IUserProfileService userProfileService, IUnitOfWork unitOfWork, - IOutboundQueueProvider outboundQueueProvider, Lazy protectedWriteService) + IOutboundQueueProvider outboundQueueProvider, Lazy protectedWriteService, + Lazy searchProjections = null) { _moderationRequestRepository = moderationRequestRepository; _moderationReportRepository = moderationReportRepository; @@ -77,6 +79,7 @@ public ModerationService(IModerationRequestRepository moderationRequestRepositor _unitOfWork = unitOfWork; _outboundQueueProvider = outboundQueueProvider; _protectedWriteService = protectedWriteService; + _searchProjections = searchProjections; } /// @@ -392,6 +395,10 @@ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestComplet throw; } + // A removed call note must leave the call's search text too. After the commit: the projection reads the note rows. + if (removeContent && request.ItemType == (int)ModerationItemType.CallNote) + await RefreshCallProjectionForNoteAsync(request.ItemId, cancellationToken); + if (!await _outboundQueueProvider.EnqueueNotification(new NotificationItem { DepartmentId = request.DepartmentId, @@ -593,6 +600,24 @@ private async Task LoadEvidenceAsync(int departmentId, strin } } + private async Task RefreshCallProjectionForNoteAsync(string itemId, CancellationToken cancellationToken) + { + if (_searchProjections == null || !int.TryParse(itemId, out var callNoteId)) + return; + try + { + var note = await _callNotesRepository.GetByIdAsync(callNoteId); + var call = note == null ? null : await _callsService.GetCallByIdAsync(note.CallId, true); + if (call != null) + await _searchProjections.Value.ProjectCallAsync(call, cancellationToken); + } + catch (Exception ex) + { + // The content is already removed; the next rebuild reconciles the projection. + Logging.LogException(ex, $"Search projection refresh failed after removing call note {callNoteId}."); + } + } + private async Task RemoveLiveContentAsync(ModerationRequest request, string byUserId, CancellationToken cancellationToken) { diff --git a/Core/Resgrid.Services/PasskeyService.cs b/Core/Resgrid.Services/PasskeyService.cs new file mode 100644 index 000000000..4e980f5f1 --- /dev/null +++ b/Core/Resgrid.Services/PasskeyService.cs @@ -0,0 +1,711 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class PasskeyService : IPasskeyService + { + /// An attestation or assertion response is a few kilobytes; anything far larger is refused unread. + internal const int MaxCredentialJsonLength = 64 * 1024; + + private readonly IUserPasskeyRepository _passkeys; + private readonly IPasskeyProvider _provider; + private readonly IRelyingPartyRegistry _registry; + private readonly IPasskeyFeatureGates _gates; + private readonly IAuthenticationChallengeService _challenges; + private readonly IMfaEvidenceService _evidence; + private readonly IMfaPolicyService _policy; + private readonly IUserSessionsRepository _sessions; + private readonly IUserSessionService _userSessions; + private readonly ISystemAuditsService _audits; + private readonly IMfaApprovalRequestRepository _approvals; + private readonly ISecurityNoticeService _notices; + private readonly TimeProvider _time; + + public PasskeyService(IUserPasskeyRepository passkeys, IPasskeyProvider provider, IRelyingPartyRegistry registry, IPasskeyFeatureGates gates, + IAuthenticationChallengeService challenges, IMfaEvidenceService evidence, IMfaPolicyService policy, IUserSessionsRepository sessions, + IUserSessionService userSessions, ISystemAuditsService audits, IMfaApprovalRequestRepository approvals, ISecurityNoticeService notices, + TimeProvider time) + { + _notices = notices; + _approvals = approvals; + _passkeys = passkeys; + _provider = provider; + _registry = registry; + _gates = gates; + _challenges = challenges; + _evidence = evidence; + _policy = policy; + _sessions = sessions; + _userSessions = userSessions; + _audits = audits; + _time = time; + } + + private static TimeSpan FirstFactorWindow => TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.FirstFactorReauthWindowMinutes)); + private static TimeSpan SecondFactorWindow => TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.SensitiveOperationWindowMinutes)); + private static int MaxPerClient => Math.Max(1, PasskeyConfig.MaxActiveCredentialsPerClient); + + // ── Registration (plan section 6.1) ─────────────────────────────────────────── + + public bool IsRegistrationAvailable(UserSessionClientApplication client) => + _gates.RegistrationEnabled && _registry.Get(client) != null && _provider.IsAvailableFor(client); + + public async Task BeginRegistrationAsync(PasskeyCaller caller, bool totpEnrolled, int recoveryCodesRemaining, + CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyCeremonyStart.Of(PasskeyOutcome.SessionRequired); + if (!IsRegistrationAvailable(caller.ClientApplication)) + return PasskeyCeremonyStart.Of(PasskeyOutcome.Unavailable); + + // The first release requires TOTP and usable recovery before a passkey, so a passkey is never the only factor. + if (!totpEnrolled || recoveryCodesRemaining <= 0) + return PasskeyCeremonyStart.Of(PasskeyOutcome.EnrollmentRequired); + + try + { + var readiness = await CheckEnrollmentEvidenceAsync(caller, _time.GetUtcNow().UtcDateTime, cancellationToken); + if (readiness != PasskeyOutcome.Succeeded) + return PasskeyCeremonyStart.Of(readiness); + + var party = _registry.Get(caller.ClientApplication); + var mine = BoundTo(await _passkeys.GetActiveForUserAsync(caller.UserId, cancellationToken), caller.ClientApplication, party.RpId); + if (mine.Count >= MaxPerClient) + return PasskeyCeremonyStart.Of(PasskeyOutcome.LimitReached); + + // One stable, random handle per account and RP; never the user id, name or e-mail (plan section 5.1). + var userHandle = await _passkeys.GetUserHandleAsync(caller.UserId, party.RpId, cancellationToken) ?? RandomNumberGenerator.GetBytes(32); + var accountName = string.IsNullOrWhiteSpace(caller.UserName) ? caller.UserId : caller.UserName; + var options = _provider.CreateRegistrationOptions(caller.ClientApplication, userHandle, accountName, accountName, + mine.Select(p => p.CredentialId).ToList(), preferRoaming: caller.SharedMode); + + var challenge = await _challenges.CreateAsync(Binding(caller, AuthenticationChallengePurpose.PasskeyRegistration, null), + party.RpId, options, cancellationToken); + return challenge == null + ? PasskeyCeremonyStart.Of(PasskeyOutcome.TooManyRequests) + : new PasskeyCeremonyStart { Outcome = PasskeyOutcome.Succeeded, RequestId = challenge.AuthenticationChallengeId, OptionsJson = options }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Passkey registration could not be started."); + return PasskeyCeremonyStart.Of(PasskeyOutcome.ServiceUnavailable); + } + } + + public async Task CompleteRegistrationAsync(PasskeyCaller caller, string requestId, string credentialJson, + string displayName, CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyRegistrationResult.Of(PasskeyOutcome.SessionRequired); + if (!IsRegistrationAvailable(caller.ClientApplication)) + return PasskeyRegistrationResult.Of(PasskeyOutcome.Unavailable); + if (!IsCeremonyInput(requestId, credentialJson)) + return PasskeyRegistrationResult.Of(PasskeyOutcome.InvalidRequest); + + var name = NormalizeDisplayName(displayName); + if (name != null && name.Length > MaxDisplayNameLength) + return PasskeyRegistrationResult.Of(PasskeyOutcome.InvalidRequest); + + try + { + var lookup = await _challenges.GetForCompletionAsync(requestId, + Binding(caller, AuthenticationChallengePurpose.PasskeyRegistration, null), cancellationToken); + if (!lookup.IsUsable) + return PasskeyRegistrationResult.Of(Map(lookup.Outcome)); + + var challenge = lookup.Challenge; + var party = _registry.Get(caller.ClientApplication); + if (!string.Equals(challenge.RpId, party.RpId, StringComparison.Ordinal)) + return PasskeyRegistrationResult.Of(PasskeyOutcome.ChallengeExpired); + + // The evidence that allowed the ceremony to start must still be current (same generation, not revoked). + var readiness = await CheckEnrollmentEvidenceAsync(caller, challenge.CreatedOnUtc, cancellationToken); + if (readiness != PasskeyOutcome.Succeeded) + return PasskeyRegistrationResult.Of(readiness); + + var verification = await _provider.VerifyRegistrationAsync(caller.ClientApplication, challenge.OptionsJson, credentialJson, cancellationToken); + if (!verification.Succeeded) + { + await _challenges.RecordFailedAttemptAsync(challenge.AuthenticationChallengeId, cancellationToken); + return PasskeyRegistrationResult.Of(PasskeyOutcome.VerificationFailed); + } + + // Spend the request before the credential exists, so one ceremony can never register twice. + if (!await _challenges.TryConsumeAsync(challenge.AuthenticationChallengeId, cancellationToken)) + return PasskeyRegistrationResult.Of(PasskeyOutcome.ChallengeConsumed); + + if (BoundTo(await _passkeys.GetActiveForUserAsync(caller.UserId, cancellationToken), caller.ClientApplication, party.RpId).Count >= MaxPerClient) + return PasskeyRegistrationResult.Of(PasskeyOutcome.LimitReached); + + var now = _time.GetUtcNow().UtcDateTime; + var session = await TryGetSessionAsync(caller.SessionId); + var passkey = new UserPasskey + { + UserPasskeyId = Guid.NewGuid().ToString(), + UserId = caller.UserId, + ClientApplication = (int)caller.ClientApplication, + RpId = party.RpId, + CredentialId = verification.CredentialId, + CredentialIdHash = SHA256.HashData(verification.CredentialId), + PublicKey = verification.PublicKey, + Algorithm = verification.Algorithm, + UserHandle = verification.UserHandle, + SignCount = verification.SignCount, + IsBackupEligible = verification.IsBackupEligible, + IsBackedUp = verification.IsBackedUp, + Transports = Truncate(string.Join(",", verification.Transports ?? Array.Empty()), 128), + Aaguid = verification.Aaguid == Guid.Empty ? null : verification.Aaguid.ToString(), + AttestationFormat = Truncate(verification.AttestationFormat, 32), + DisplayName = name ?? DefaultDisplayName(caller.ClientApplication, now), + CreatedOnUtc = now, + RegistrationPlatform = Truncate(session?.OperatingSystem ?? session?.DeviceType, 128), + RegistrationInstallation = Truncate(session?.DeviceName, 256), + RegistrationUserAgentFamily = Truncate(session?.Browser, 128), + RegistrationAttachment = verification.Attachment, + RegisteredInSharedMode = caller.SharedMode, + StateVersion = 1 + }; + + // The (RP, credential id) key is unique across users, so a credential already registered anywhere is refused. + if (!await _passkeys.TryInsertAsync(passkey, cancellationToken)) + return PasskeyRegistrationResult.Of(PasskeyOutcome.VerificationFailed); + + await AuditAsync(caller, SystemAuditTypes.PasskeyRegistered, + $"Passkey {passkey.UserPasskeyId} registered for {ClientLabel(caller.ClientApplication)}.", cancellationToken); + await NoticeAsync(caller, SecurityNoticeKind.PasskeyRegistered, cancellationToken); + // A passkey created on a shared installation may sit in its common profile (plan sections 6.5 and 12.5.2). + if (caller.SharedMode) + await NoticeAsync(caller, SecurityNoticeKind.SharedInstallationFactor, cancellationToken); + return new PasskeyRegistrationResult { Outcome = PasskeyOutcome.Succeeded, Passkey = passkey }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Passkey registration could not be completed."); + return PasskeyRegistrationResult.Of(PasskeyOutcome.ServiceUnavailable); + } + } + + // ── Inventory and revocation (plan sections 6.1 and 6.5) ────────────────────── + + public Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) => + _passkeys.GetActiveForUserAsync(userId, cancellationToken); + + public async Task HasActiveForClientAsync(string userId, UserSessionClientApplication client, CancellationToken cancellationToken = default) + { + var party = _registry.Get(client); + return party != null && !string.IsNullOrWhiteSpace(userId) && + BoundTo(await _passkeys.GetActiveForUserAsync(userId, cancellationToken), client, party.RpId).Count > 0; + } + + public Task CountActiveForUserAsync(string userId, CancellationToken cancellationToken = default) => + _passkeys.CountActiveForUserAsync(userId, cancellationToken); + + public async Task RenameAsync(PasskeyCaller caller, string userPasskeyId, string displayName, + CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyOutcome.SessionRequired; + + var name = NormalizeDisplayName(displayName); + if (string.IsNullOrEmpty(name) || name.Length > MaxDisplayNameLength || string.IsNullOrWhiteSpace(userPasskeyId)) + return PasskeyOutcome.InvalidRequest; + + if (!await _passkeys.TryRenameAsync(userPasskeyId, caller.UserId, name, cancellationToken)) + return PasskeyOutcome.NotFound; + + await AuditAsync(caller, SystemAuditTypes.PasskeyRenamed, $"Passkey {userPasskeyId} renamed.", cancellationToken); + return PasskeyOutcome.Succeeded; + } + + public async Task RevokeAsync(PasskeyCaller caller, string userPasskeyId, CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyRevocationResult.Of(PasskeyOutcome.SessionRequired); + if (string.IsNullOrWhiteSpace(userPasskeyId)) + return PasskeyRevocationResult.Of(PasskeyOutcome.InvalidRequest); + + var now = _time.GetUtcNow().UtcDateTime; + if (!await HasRecentAccountMfaAsync(caller, now, cancellationToken)) + return PasskeyRevocationResult.Of(PasskeyOutcome.StepUpRequired); + + var passkey = await _passkeys.GetAsync(userPasskeyId, cancellationToken); + if (passkey == null || !passkey.IsActive || !SameUser(passkey.UserId, caller.UserId)) + return PasskeyRevocationResult.Of(PasskeyOutcome.NotFound); + + if (!await _passkeys.TryRevokeAsync(userPasskeyId, caller.UserId, PasskeyRevocationReason.RemovedByUser, caller.UserId, now, cancellationToken)) + return PasskeyRevocationResult.Of(PasskeyOutcome.NotFound); + + await RetireDerivedStateAsync(caller.UserId, new[] { userPasskeyId }, cancellationToken); + var (ended, currentEnded) = await EndLoginSessionsAsync(caller, new[] { userPasskeyId }, cancellationToken); + await AuditAsync(caller, SystemAuditTypes.PasskeyRevoked, + $"Passkey {userPasskeyId} ({ClientLabel((UserSessionClientApplication)passkey.ClientApplication)}) removed; " + + $"{ended} session(s) that signed in with it ended.", cancellationToken); + await NoticeAsync(caller, SecurityNoticeKind.PasskeyRemoved, cancellationToken); + return new PasskeyRevocationResult { Outcome = PasskeyOutcome.Succeeded, Revoked = 1, SessionsEnded = ended, CurrentSessionEnded = currentEnded }; + } + + public async Task RevokeAllForClientAsync(PasskeyCaller caller, UserSessionClientApplication client, + CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyRevocationResult.Of(PasskeyOutcome.SessionRequired); + + var now = _time.GetUtcNow().UtcDateTime; + if (!await HasRecentAccountMfaAsync(caller, now, cancellationToken)) + return PasskeyRevocationResult.Of(PasskeyOutcome.StepUpRequired); + + var revoked = await _passkeys.RevokeAllForClientAsync(caller.UserId, (int)client, PasskeyRevocationReason.RemovedAllForClient, + caller.UserId, now, cancellationToken); + if (revoked.Count == 0) + return new PasskeyRevocationResult { Outcome = PasskeyOutcome.Succeeded }; + + await RetireDerivedStateAsync(caller.UserId, revoked, cancellationToken); + var (ended, currentEnded) = await EndLoginSessionsAsync(caller, revoked, cancellationToken); + await AuditAsync(caller, SystemAuditTypes.PasskeyRevoked, + $"All {revoked.Count} passkey(s) for {ClientLabel(client)} removed: {string.Join(", ", revoked)}; " + + $"{ended} session(s) that signed in with them ended.", cancellationToken); + await NoticeAsync(caller, SecurityNoticeKind.PasskeyRemoved, cancellationToken); + return new PasskeyRevocationResult { Outcome = PasskeyOutcome.Succeeded, Revoked = revoked.Count, SessionsEnded = ended, CurrentSessionEnded = currentEnded }; + } + + public async Task SetApprovalEnabledAsync(PasskeyCaller caller, string userPasskeyId, bool enabled, + CancellationToken cancellationToken = default) + { + if (caller?.SessionKey == null) + return PasskeyOutcome.SessionRequired; + if (string.IsNullOrWhiteSpace(userPasskeyId)) + return PasskeyOutcome.InvalidRequest; + + // Turning approval off is always allowed; turning it on needs the approval feature. + if (enabled && !_gates.ResponderApprovalEnabled) + return PasskeyOutcome.Unavailable; + + // Turning it on needs a fresh assertion with that very passkey in this Responder session (plan section 7.9 + // eligibility); turning it off, any accepted account factor. + var now = _time.GetUtcNow().UtcDateTime; + if (enabled ? !await HasFreshAssertionWithAsync(caller, userPasskeyId, now, cancellationToken) + : !await HasRecentAccountMfaAsync(caller, now, cancellationToken)) + return PasskeyOutcome.StepUpRequired; + + var passkey = await _passkeys.GetAsync(userPasskeyId, cancellationToken); + if (passkey == null || !passkey.IsActive || !SameUser(passkey.UserId, caller.UserId)) + return PasskeyOutcome.NotFound; + + // Only a Responder passkey can approve another app's request (plan section 7.9). + if (passkey.ClientApplication != (int)UserSessionClientApplication.Responder) + return PasskeyOutcome.InvalidRequest; + + if (!await _passkeys.TrySetApprovalEnabledAsync(userPasskeyId, caller.UserId, enabled, cancellationToken)) + return PasskeyOutcome.NotFound; + + // Turning approval off retires what this passkey approved, as removing it would (plan section 7.9 revocation): + // pending requests end, sign-ins it approved end, and its approval evidence stops counting on the next read. + if (!enabled) + { + await CancelPendingApprovalsAsync(caller.UserId, cancellationToken); + await EndLoginSessionsAsync(caller, new[] { userPasskeyId }, cancellationToken, approvalsOnly: true); + } + + await AuditAsync(caller, SystemAuditTypes.PasskeyApprovalChanged, + $"Passkey {userPasskeyId} {(enabled ? "may now approve" : "no longer approves")} other apps' requests.", cancellationToken); + await NoticeAsync(caller, enabled ? SecurityNoticeKind.ApprovalTurnedOn : SecurityNoticeKind.ApprovalTurnedOff, cancellationToken); + return PasskeyOutcome.Succeeded; + } + + // ── Assertions for step-up (plan sections 7.6 and 11) ───────────────────────── + + public async Task BeginAssertionAsync(PasskeyCaller caller, AuthenticationChallengePurpose purpose, + CancellationToken cancellationToken = default) + { + if (caller?.ChallengeParentId == null) + return PasskeyCeremonyStart.Of(PasskeyOutcome.SessionRequired); + if (!ParentFits(caller, purpose)) + return PasskeyCeremonyStart.Of(PasskeyOutcome.InvalidRequest); + if (!IsAssertionAvailable(caller.ClientApplication, purpose)) + return PasskeyCeremonyStart.Of(PasskeyOutcome.Unavailable); + + try + { + var party = _registry.Get(caller.ClientApplication); + var mine = BoundTo(await _passkeys.GetActiveForUserAsync(caller.UserId, cancellationToken), caller.ClientApplication, party.RpId) + .Where(p => purpose != AuthenticationChallengePurpose.ApprovalResponse || p.ApprovalEnabled) + .ToList(); + if (mine.Count == 0) + return PasskeyCeremonyStart.Of(PasskeyOutcome.NotRegisteredForClient); + + var options = _provider.CreateAssertionOptions(caller.ClientApplication, mine.Select(p => p.CredentialId).ToList()); + var challenge = await _challenges.CreateAsync(Binding(caller, purpose, caller.DepartmentId), party.RpId, options, cancellationToken); + return challenge == null + ? PasskeyCeremonyStart.Of(PasskeyOutcome.TooManyRequests) + : new PasskeyCeremonyStart { Outcome = PasskeyOutcome.Succeeded, RequestId = challenge.AuthenticationChallengeId, OptionsJson = options }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Passkey verification could not be started."); + return PasskeyCeremonyStart.Of(PasskeyOutcome.ServiceUnavailable); + } + } + + public async Task CompleteAssertionAsync(PasskeyCaller caller, AuthenticationChallengePurpose purpose, string requestId, + string credentialJson, CancellationToken cancellationToken = default) + { + if (caller?.ChallengeParentId == null) + return PasskeyAssertionResult.Of(PasskeyOutcome.SessionRequired); + if (!ParentFits(caller, purpose)) + return PasskeyAssertionResult.Of(PasskeyOutcome.InvalidRequest); + if (!IsAssertionAvailable(caller.ClientApplication, purpose)) + return PasskeyAssertionResult.Of(PasskeyOutcome.Unavailable); + if (!IsCeremonyInput(requestId, credentialJson)) + return PasskeyAssertionResult.Of(PasskeyOutcome.InvalidRequest); + + try + { + var lookup = await _challenges.GetForCompletionAsync(requestId, Binding(caller, purpose, caller.DepartmentId), cancellationToken); + if (!lookup.IsUsable) + return PasskeyAssertionResult.Of(Map(lookup.Outcome)); + + var challenge = lookup.Challenge; + var party = _registry.Get(caller.ClientApplication); + if (!string.Equals(challenge.RpId, party.RpId, StringComparison.Ordinal)) + return PasskeyAssertionResult.Of(PasskeyOutcome.ChallengeExpired); + + // The library verifies against whatever key it is handed, so the credential is loaded here: it must be this + // user's, active, and bound to the client asking (plan section 3 item 14). + var credentialId = _provider.ReadCredentialId(credentialJson); + var stored = credentialId == null ? null : await _passkeys.GetActiveByCredentialAsync(party.RpId, SHA256.HashData(credentialId), cancellationToken); + if (stored == null || !SameUser(stored.UserId, caller.UserId) || stored.ClientApplication != (int)caller.ClientApplication + || stored.CredentialId == null || !stored.CredentialId.AsSpan().SequenceEqual(credentialId) + || (purpose == AuthenticationChallengePurpose.ApprovalResponse && !stored.ApprovalEnabled)) + { + await _challenges.RecordFailedAttemptAsync(challenge.AuthenticationChallengeId, cancellationToken); + return PasskeyAssertionResult.Of(PasskeyOutcome.NotRegisteredForClient); + } + + var verification = await _provider.VerifyAssertionAsync(caller.ClientApplication, challenge.OptionsJson, credentialJson, + new PasskeyAssertionCredential + { + CredentialId = stored.CredentialId, + PublicKey = stored.PublicKey, + UserHandle = stored.UserHandle, + SignCount = stored.SignCount + }, cancellationToken); + if (!verification.Succeeded) + { + await _challenges.RecordFailedAttemptAsync(challenge.AuthenticationChallengeId, cancellationToken); + return PasskeyAssertionResult.Of(PasskeyOutcome.VerificationFailed); + } + + // The library accepts a replayed assertion; the single-use request is what refuses it. + if (!await _challenges.TryConsumeAsync(challenge.AuthenticationChallengeId, cancellationToken)) + return PasskeyAssertionResult.Of(PasskeyOutcome.ChallengeConsumed); + + // Recorded only if the counter is still the one verified against and the passkey was not revoked meanwhile. + var now = _time.GetUtcNow().UtcDateTime; + var session = caller.SessionId == null ? null : await TryGetSessionAsync(caller.SessionId); + if (!await _passkeys.TryRecordUseAsync(stored.UserPasskeyId, stored.SignCount, verification.SignCount, verification.IsBackedUp, + (int)caller.ClientApplication, Truncate(session?.DeviceName, 256), caller.SharedMode, now, cancellationToken)) + return PasskeyAssertionResult.Of(PasskeyOutcome.VerificationFailed); + + return new PasskeyAssertionResult { Outcome = PasskeyOutcome.Succeeded, Passkey = stored, VerifiedOnUtc = now }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Passkey verification could not be completed."); + return PasskeyAssertionResult.Of(PasskeyOutcome.ServiceUnavailable); + } + } + + // ── Rules ───────────────────────────────────────────────────────────────────── + + private static int MaxDisplayNameLength => Math.Max(1, PasskeyConfig.MaxDisplayNameLength); + + /// + /// A new credential needs a password or SSO verification for this session within five minutes, and an accepted + /// second factor within five minutes (plan section 6.1 item 1), both under the user's current generation. + /// + private async Task CheckEnrollmentEvidenceAsync(PasskeyCaller caller, DateTime asOfUtc, CancellationToken cancellationToken) + { + var firstFactor = await _evidence.GetLatestFirstFactorAsync(caller.UserId, caller.SessionKey, caller.AuthenticationGeneration, cancellationToken); + if (!MfaEvidenceService.IsFresh(firstFactor, FirstFactorWindow, asOfUtc)) + return PasskeyOutcome.ReauthenticationRequired; + + return await HasRecentAccountMfaAsync(caller, asOfUtc, cancellationToken) ? PasskeyOutcome.Succeeded : PasskeyOutcome.StepUpRequired; + } + + /// + /// Account factor management accepts TOTP or a passkey bound to the calling client, verified for this session within + /// five minutes; never Responder approval or provider step-up (plan section 7.6 row 14). + /// + private async Task HasRecentAccountMfaAsync(PasskeyCaller caller, DateTime asOfUtc, CancellationToken cancellationToken) + { + var latest = await _evidence.GetLatestSecondFactorAsync(caller.UserId, caller.SessionKey, caller.AuthenticationGeneration, cancellationToken); + if (!MfaEvidenceService.IsFresh(latest, SecondFactorWindow, asOfUtc)) + return false; + + var method = (MfaEvidenceMethod)latest.Method; + if (method == MfaEvidenceMethod.Passkey && latest.ClientApplication != (int)caller.ClientApplication) + return false; + + return (method == MfaEvidenceMethod.Totp || method == MfaEvidenceMethod.Passkey) && + await _policy.IsMethodAcceptedAsync(null, MfaMethodScope.Account, method, cancellationToken); + } + + /// The session's latest second factor is an assertion with this passkey, bound to the calling client, within five minutes. + private async Task HasFreshAssertionWithAsync(PasskeyCaller caller, string userPasskeyId, DateTime asOfUtc, CancellationToken cancellationToken) + { + var latest = await _evidence.GetLatestSecondFactorAsync(caller.UserId, caller.SessionKey, caller.AuthenticationGeneration, cancellationToken); + return MfaEvidenceService.IsFresh(latest, SecondFactorWindow, asOfUtc) && latest.Method == (int)MfaEvidenceMethod.Passkey && + latest.ClientApplication == (int)caller.ClientApplication && + string.Equals(latest.FactorReference, UserPasskey.FactorReferenceFor(userPasskeyId), StringComparison.Ordinal); + } + + /// + /// A removed passkey stops counting at once: the evidence it produced is revoked and the user's pending ceremonies + /// are canceled (plan section 6.1 item 8). A failure here never undoes the revocation. + /// + private async Task RetireDerivedStateAsync(string userId, IEnumerable userPasskeyIds, CancellationToken cancellationToken) + { + try + { + foreach (var id in userPasskeyIds) + await _evidence.RevokeForFactorAsync(userId, UserPasskey.FactorReferenceFor(id), cancellationToken); + await _challenges.CancelPendingForUserAsync(userId, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Evidence or challenges derived from a removed passkey could not be retired."); + } + + // Approvals it made stop counting on the next evidence read; requests still waiting for it end now. + await CancelPendingApprovalsAsync(userId, cancellationToken); + } + + /// Pending Responder approval requests end when an approving passkey goes (plan section 7.9 revocation). + private async Task CancelPendingApprovalsAsync(string userId, CancellationToken cancellationToken) + { + try + { + await _approvals.CancelPendingForUserAsync(userId, MfaApprovalEndReason.ApproverRevoked, _time.GetUtcNow().UtcDateTime, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Pending approval requests for a removed approver could not be ended."); + } + } + + /// + /// Ends the caller's active sessions whose sign-in was verified with one of the removed passkeys (plan section 6.1 + /// item 8). Sessions that used another factor are untouched. A failure here never undoes the revocation. + /// + private async Task<(int Ended, bool CurrentEnded)> EndLoginSessionsAsync(PasskeyCaller caller, IReadOnlyCollection userPasskeyIds, + CancellationToken cancellationToken, bool approvalsOnly = false) + { + try + { + // A sign-in verified with the passkey itself, or approved with it from Responder (plan section 7.9). + var references = approvalsOnly + ? new HashSet(StringComparer.Ordinal) + : new HashSet(userPasskeyIds.Select(UserPasskey.FactorReferenceFor), StringComparer.Ordinal); + var approvalPrefixes = userPasskeyIds.Select(MfaApprovalRequest.FactorReferencePrefixFor).ToList(); + bool UsedIt(string reference) => reference != null && + (references.Contains(reference) || approvalPrefixes.Any(prefix => reference.StartsWith(prefix, StringComparison.Ordinal))); + + var sessions = await _sessions.GetActiveByUserAsync(caller.UserId, _time.GetUtcNow().UtcDateTime); + var ended = 0; + var currentEnded = false; + foreach (var session in sessions.Where(s => UsedIt(s.LoginMfaFactorReference))) + { + var result = await _userSessions.RevokeSessionAsync(caller.UserId, caller.UserId, session.UserSessionId, + UserSessionRevocationReason.MfaChanged, cancellationToken); + if (result.RevokedSessionCount > 0) + { + ended += result.RevokedSessionCount; + currentEnded |= string.Equals(session.UserSessionId, caller.SessionId, StringComparison.Ordinal); + } + } + + return (ended, currentEnded); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Sessions that signed in with a removed passkey could not be ended."); + return (0, false); + } + } + + private bool IsAssertionAvailable(UserSessionClientApplication client, AuthenticationChallengePurpose purpose) + { + var gateOn = purpose switch + { + AuthenticationChallengePurpose.LoginSecondFactor => _gates.LoginAcceptanceEnabled, + AuthenticationChallengePurpose.SensitiveOperation => _gates.LoginAcceptanceEnabled || _gates.AdpAcceptanceEnabled, + AuthenticationChallengePurpose.AdpStepUp => _gates.AdpAcceptanceEnabled, + // Unlocking a shared session follows the sign-in passkey rules (plan section 12.5.3). + AuthenticationChallengePurpose.SharedDeviceUnlock => _gates.LoginAcceptanceEnabled, + // Only a Responder passkey with approval on answers another app's request (plan section 7.9). + AuthenticationChallengePurpose.ApprovalResponse => _gates.ResponderApprovalEnabled && client == UserSessionClientApplication.Responder, + _ => false + }; + return gateOn && _registry.Get(client) != null && _provider.IsAvailableFor(client); + } + + /// + /// A login second factor is bound to its login transaction, an approval response to its approval request (from the + /// approver's own session); every other ceremony to a signed-in session. + /// + private static bool ParentFits(PasskeyCaller caller, AuthenticationChallengePurpose purpose) => purpose switch + { + AuthenticationChallengePurpose.LoginSecondFactor => caller.ChallengeParentKind == AuthenticationChallengeParentKind.LoginTransaction, + AuthenticationChallengePurpose.ApprovalResponse => caller.ChallengeParentKind == AuthenticationChallengeParentKind.ApprovalRequest && + !string.IsNullOrWhiteSpace(caller.SessionId), + _ => caller.ChallengeParentKind == AuthenticationChallengeParentKind.Session + }; + + private static AuthenticationChallengeBinding Binding(PasskeyCaller caller, AuthenticationChallengePurpose purpose, int? departmentId) => new() + { + UserId = caller.UserId, + Purpose = purpose, + ClientApplication = caller.ClientApplication, + ParentKind = caller.ChallengeParentKind, + ParentId = caller.ChallengeParentId, + DepartmentId = departmentId, + AuthenticationGeneration = caller.AuthenticationGeneration, + LockVersion = caller.SessionLockVersion + }; + + /// The caller's active passkeys for the client's current RP; credentials from a retired RP never count. + private static List BoundTo(IEnumerable passkeys, UserSessionClientApplication client, string rpId) => + (passkeys ?? Enumerable.Empty()) + .Where(p => p.IsActive && p.ClientApplication == (int)client && string.Equals(p.RpId, rpId, StringComparison.Ordinal)) + .ToList(); + + private static PasskeyOutcome Map(AuthenticationChallengeOutcome outcome) => outcome switch + { + AuthenticationChallengeOutcome.AlreadyUsed => PasskeyOutcome.ChallengeConsumed, + AuthenticationChallengeOutcome.TooManyAttempts => PasskeyOutcome.TooManyAttempts, + AuthenticationChallengeOutcome.Unavailable => PasskeyOutcome.ServiceUnavailable, + // Not found, expired, stale and a binding mismatch look the same: the ceremony has to start again. + _ => PasskeyOutcome.ChallengeExpired + }; + + private static bool IsCeremonyInput(string requestId, string credentialJson) => + !string.IsNullOrWhiteSpace(requestId) && requestId.Length <= 64 && + !string.IsNullOrWhiteSpace(credentialJson) && credentialJson.Length <= MaxCredentialJsonLength; + + private static bool SameUser(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + /// + /// A display name is user text: control and invisible formatting characters (including bidirectional overrides) + /// are removed and whitespace collapsed. It is escaped again wherever it is shown. + /// + internal static string NormalizeDisplayName(string displayName) + { + if (displayName == null) + return null; + + var builder = new StringBuilder(displayName.Length); + var pendingSpace = false; + foreach (var ch in displayName) + { + var category = char.GetUnicodeCategory(ch); + if (category == UnicodeCategory.Format) + continue; + + if (category is UnicodeCategory.Control or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator || char.IsWhiteSpace(ch)) + { + pendingSpace = builder.Length > 0; + continue; + } + + if (pendingSpace) + builder.Append(' '); + pendingSpace = false; + builder.Append(ch); + } + + return builder.Length == 0 ? null : builder.ToString(); + } + + internal static string ClientLabel(UserSessionClientApplication client) => client switch + { + UserSessionClientApplication.Web => "Resgrid Web", + UserSessionClientApplication.Responder => "Responder", + UserSessionClientApplication.Unit => "Unit", + UserSessionClientApplication.Dispatch => "Dispatch", + UserSessionClientApplication.Command => "IC", + _ => client.ToString() + }; + + private static string DefaultDisplayName(UserSessionClientApplication client, DateTime createdOnUtc) => + $"{ClientLabel(client)} passkey ({createdOnUtc:yyyy-MM-dd})"; + + private static string Truncate(string value, int length) => + string.IsNullOrWhiteSpace(value) ? null : value.Length <= length ? value : value[..length]; + + /// Server-observed context from the session record; labels only, so a failed read leaves them empty. + private async Task TryGetSessionAsync(string sessionId) + { + try + { + return await _sessions.GetByIdAsync(sessionId); + } + catch (Exception ex) + { + Logging.LogException(ex, "Session context for a passkey record could not be read."); + return null; + } + } + + /// A security notice to the account holder (plan section 6.4), naming the app and installation the change came from. + private async Task NoticeAsync(PasskeyCaller caller, SecurityNoticeKind kind, CancellationToken cancellationToken) + { + var session = caller.SessionId == null ? null : await TryGetSessionAsync(caller.SessionId); + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = caller.UserId, + Kind = kind, + ClientApplication = caller.ClientApplication, + InstallationLabel = session?.DeviceName, + Region = SecurityNotices.Region(session?.LastRegion, session?.LastCountry) + }, cancellationToken); + } + + private async Task AuditAsync(PasskeyCaller caller, SystemAuditTypes type, string data, CancellationToken cancellationToken) + { + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)caller.AuditSystem, + Type = (int)type, + UserId = caller.UserId, + Username = caller.UserName, + Successful = true, + IpAddress = caller.IpAddress, + ServerName = Environment.MachineName, + Data = data + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The change already committed; a lost audit row is logged loudly rather than undoing it. + Logging.LogException(ex, $"Passkey audit ({type}) could not be saved."); + } + } + } +} diff --git a/Core/Resgrid.Services/PersonnelRolesService.cs b/Core/Resgrid.Services/PersonnelRolesService.cs index b34ba511c..2e5c2623c 100644 --- a/Core/Resgrid.Services/PersonnelRolesService.cs +++ b/Core/Resgrid.Services/PersonnelRolesService.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -22,11 +23,14 @@ public class PersonnelRolesService : IPersonnelRolesService private readonly IUnitOfWork _unitOfWork; // Lazy: the certification service evaluates role requirements and calls back here for members (plan D4). private readonly Lazy _certifications; + private readonly Lazy _searchProjections; public PersonnelRolesService(IPersonnelRolesRepository personnelRolesRepository, IPersonnelRoleUsersRepository personnelRoleUsersRepository, ISubscriptionsService subscriptionsService, IDepartmentMembersRepository departmentMemberRepository, - IEventAggregator eventAggregator, IUnitOfWork unitOfWork, Lazy certifications = null) + IEventAggregator eventAggregator, IUnitOfWork unitOfWork, Lazy certifications = null, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _personnelRolesRepository = personnelRolesRepository; _personnelRoleUsersRepository = personnelRoleUsersRepository; _subscriptionsService = subscriptionsService; @@ -138,9 +142,19 @@ public async Task> GetAllRolesForDepartmentAsync(int departm var saved = await _personnelRolesRepository.SaveOrUpdateAsync(role, cancellationToken); foreach (var userId in added) AuditMembership(role.DepartmentId, actingUserId, AuditLogTypes.RoleMemberAdded, userId, saved.PersonnelRoleId, saved.Name); + // Member projections carry role names: a rename touches every member, a membership change the members it moved. + await RefreshPersonnelProjectionsAsync(role.DepartmentId, previous.Concat(incoming), cancellationToken); return saved; } + private async Task RefreshPersonnelProjectionsAsync(int departmentId, IEnumerable userIds, CancellationToken cancellationToken) + { + if (_searchProjections == null || userIds == null) + return; + foreach (var userId in userIds.Where(u => !string.IsNullOrWhiteSpace(u)).Distinct(StringComparer.OrdinalIgnoreCase)) + await _searchProjections.Value.RefreshAsync(departmentId, SearchEntityTypes.Personnel, userId, cancellationToken); + } + public async Task ReplaceRoleMembersAsync(PersonnelRole role, IEnumerable userIds, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null) { if (role == null || role.PersonnelRoleId <= 0) @@ -189,6 +203,7 @@ public async Task> GetAllRolesForDepartmentAsync(int departm foreach (var userId in added) AuditMembership(role.DepartmentId, actingUserId, AuditLogTypes.RoleMemberAdded, userId, saved.PersonnelRoleId, saved.Name); SendRoleVisibilityRefresh(role.DepartmentId); + await RefreshPersonnelProjectionsAsync(role.DepartmentId, current.Select(m => m.UserId).Concat(incoming), cancellationToken); return saved; } @@ -220,6 +235,10 @@ public async Task GetRoleByDepartmentAndNameAsync(int departmentI if (role == null) return false; + var formerMembers = _searchProjections != null + ? (await _personnelRoleUsersRepository.GetAllMembersOfRoleAsync(roleId) ?? Enumerable.Empty()).Select(m => m.UserId).ToList() + : null; + // Call dispatches, shift group requirements, run cards and the rest all point back at the // role row; CallDispatchRoles has a non-cascading FK, so the delete below fails outright for // any role that has ever been dispatched unless those rows go first. Both steps share one @@ -242,6 +261,7 @@ public async Task GetRoleByDepartmentAndNameAsync(int departmentI } SendRoleVisibilityRefresh(role.DepartmentId); + await RefreshPersonnelProjectionsAsync(role.DepartmentId, formerMembers, cancellationToken); return result; } @@ -260,7 +280,10 @@ public async Task GetRoleByDepartmentAndNameAsync(int departmentI if (users != null) { foreach (var departmentId in users.Where(x => x != null).Select(x => x.DepartmentId).Distinct()) + { SendRoleVisibilityRefresh(departmentId); + await RefreshPersonnelProjectionsAsync(departmentId, users.Where(x => x != null && x.DepartmentId == departmentId).Select(x => x.UserId), cancellationToken); + } } return true; @@ -294,6 +317,15 @@ where users.Select(x => x.UserId).Contains(rolesGroup.Key) } public async Task RemoveUserFromAllRolesAsync(string userId, int departmentId, CancellationToken cancellationToken = default(CancellationToken)) + { + await RemoveUserRoleRowsAsync(userId, departmentId, cancellationToken); + SendRoleVisibilityRefresh(departmentId); + await RefreshPersonnelProjectionsAsync(departmentId, new[] { userId }, cancellationToken); + + return true; + } + + private async Task RemoveUserRoleRowsAsync(string userId, int departmentId, CancellationToken cancellationToken) { var personnelRoleUsers = await _personnelRoleUsersRepository.GetAllRoleUsersForUserAsync(departmentId, userId); @@ -301,10 +333,6 @@ where users.Select(x => x.UserId).Contains(rolesGroup.Key) { await _personnelRoleUsersRepository.DeleteAsync(personnelRoleUser, cancellationToken); } - - SendRoleVisibilityRefresh(departmentId); - - return true; } public async Task SetRolesForUserAsync(int departmentId, string userId, string[] roleIds, CancellationToken cancellationToken = default(CancellationToken), string actingUserId = null) @@ -320,7 +348,7 @@ where users.Select(x => x.UserId).Contains(rolesGroup.Key) if (gaining.Count > 0 && (await CheckRoleMembershipAsync(departmentId, userId, gaining)).IsBlocked) throw new RoleMembershipException(RoleMembershipException.RequirementsUnmet, userId); - await RemoveUserFromAllRolesAsync(userId, departmentId, cancellationToken); + await RemoveUserRoleRowsAsync(userId, departmentId, cancellationToken); foreach (var role in wanted) { @@ -338,6 +366,7 @@ where users.Select(x => x.UserId).Contains(rolesGroup.Key) AuditMembership(departmentId, actingUserId, AuditLogTypes.RoleMemberRemoved, userId, roleId, roles.FirstOrDefault(r => r.PersonnelRoleId == roleId)?.Name); SendRoleVisibilityRefresh(departmentId); + await RefreshPersonnelProjectionsAsync(departmentId, new[] { userId }, cancellationToken); return true; } diff --git a/Core/Resgrid.Services/ProtectedDataGrantService.cs b/Core/Resgrid.Services/ProtectedDataGrantService.cs index 80b2cbd0c..0e453d9f5 100644 --- a/Core/Resgrid.Services/ProtectedDataGrantService.cs +++ b/Core/Resgrid.Services/ProtectedDataGrantService.cs @@ -29,6 +29,19 @@ public class ProtectedDataGrantService : IProtectedDataGrantService private const string ScopeClaim = "scope"; private const string AmrClaim = "amr"; + // Version 2 claims (passkey plan section 8.2, workbook section 7.5). + private const string VersionClaim = "grant_ver"; + private const string MfaMethodClaim = "mfa_method"; + private const string MfaCredentialClaim = "mfa_credential_id"; + private const string MfaStateVersionClaim = "mfa_state_ver"; + private const string StepUpExemptClaim = "step_up_exempt"; + private const string SessionLockVersionClaim = "session_lock_version"; + private const string AuthenticationGenerationClaim = "auth_gen"; + + // amr for version 2: the first factor (pwd or fed), otp for TOTP, and mfa whenever a second factor was verified. + // Nothing else: Resgrid cannot establish hwk, face or fpt, so a grant claiming them is malformed. + private static readonly HashSet VersionTwoAmr = new(StringComparer.Ordinal) { "pwd", "fed", "otp", "mfa" }; + private static readonly JwtSecurityTokenHandler TokenHandler = new JwtSecurityTokenHandler(); // Lazy with ExecutionAndPublication provides the safe publication a hand-rolled @@ -77,6 +90,10 @@ public ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest r throw new ArgumentException("A grant requires at least one non-empty scope.", nameof(request)); if (request.PolicyEpoch < 0) throw new ArgumentException("Policy epoch cannot be negative.", nameof(request)); + if (request.Version != 1 && request.Version != 2) + throw new ArgumentException("Only grant versions 1 and 2 can be issued.", nameof(request)); + if (request.Version == 2) + ValidateVersionTwoRequest(request); var signingCertificate = GetSigningCertificate(); if (signingCertificate == null) @@ -90,6 +107,10 @@ public ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest r var now = DateTime.UtcNow; var expires = now.AddMinutes(windowMinutes); var grantId = Guid.NewGuid().ToString("N"); + + if (request.Version == 2) + return IssueVersionTwo(request, signingCertificate, now, expires, grantId); + var mfaAt = request.MfaAtUtc == default ? now : request.MfaAtUtc; var claims = new List @@ -188,6 +209,18 @@ public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expec if (parsedToken.ValidFrom != DateTime.MinValue && now < parsedToken.ValidFrom.Subtract(skew)) return ProtectedDataGrantValidationOutcome.Invalid; + // A grant without grant_ver is version 1. Any version this build does not read is refused outright, so a + // future contract can never be half-understood by an older reader. + var version = 1; + if (parsedToken.Payload.ContainsKey(VersionClaim)) + { + if (!TryGetInt64(parsedToken.Payload, VersionClaim, out var declaredVersion)) + return ProtectedDataGrantValidationOutcome.Invalid; + if (declaredVersion != 2) + return ProtectedDataGrantValidationOutcome.VersionUnsupported; + version = 2; + } + if (!int.TryParse(principal.FindFirst(DepartmentClaim)?.Value, out var departmentId)) return ProtectedDataGrantValidationOutcome.Invalid; if (departmentId != expectedDepartmentId) @@ -210,9 +243,29 @@ public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expec if (string.IsNullOrWhiteSpace(userId)) return ProtectedDataGrantValidationOutcome.Invalid; + if (version == 2) + { + if (!TryReadVersionTwo(parsedToken, now, skew, out var versionTwo)) + return ProtectedDataGrantValidationOutcome.Invalid; + + versionTwo.GrantId = parsedToken.Id; + versionTwo.UserId = userId; + versionTwo.DepartmentId = departmentId; + versionTwo.PolicyEpoch = policyEpoch; + versionTwo.Scopes = scopes; + versionTwo.IssuedAtUtc = parsedToken.IssuedAt; + versionTwo.ExpiresOnUtc = parsedToken.ValidTo; + grant = versionTwo; + return ProtectedDataGrantValidationOutcome.Valid; + } + int.TryParse(principal.FindFirst(ClientAppClaim)?.Value, out var clientApp); long.TryParse(principal.FindFirst(MfaAtClaim)?.Value, out var mfaAtSeconds); + // amr is read from the token itself: the handler's inbound claim mapping renames "amr" on the principal, + // so a principal lookup never found it and every version 1 grant read as step-up exempt. + var amr = RawStrings(parsedToken, AmrClaim); + grant = new ProtectedDataGrant { GrantId = principal.FindFirst(JwtRegisteredClaimNames.Jti)?.Value, @@ -223,13 +276,253 @@ public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expec PolicyEpoch = policyEpoch, Scopes = scopes, MfaAtUtc = DateTimeOffset.FromUnixTimeSeconds(mfaAtSeconds).UtcDateTime, - StepUpExempt = !string.Equals(principal.FindFirst(AmrClaim)?.Value, "otp", StringComparison.Ordinal), + StepUpExempt = !amr.Contains("otp", StringComparer.Ordinal), IssuedAtUtc = parsedToken.IssuedAt, - ExpiresOnUtc = parsedToken.ValidTo + ExpiresOnUtc = parsedToken.ValidTo, + Version = 1, + Amr = amr }; return ProtectedDataGrantValidationOutcome.Valid; } + private static void ValidateVersionTwoRequest(ProtectedDataGrantIssueRequest request) + { + if (string.IsNullOrWhiteSpace(request.SessionId)) + throw new ArgumentException("A version 2 grant is bound to a session.", nameof(request)); + if (!IsGrantClient(request.ClientApp)) + throw new ArgumentException("A version 2 grant is bound to a known client application.", nameof(request)); + if (request.AuthenticationGeneration is not >= 0) + throw new ArgumentException("A version 2 grant carries the account authentication generation.", nameof(request)); + if (!ProtectedDataGrantMfaMethods.IsKnown(request.MfaMethod)) + throw new ArgumentException("A version 2 grant names a known MFA method.", nameof(request)); + if (request.StepUpExempt != (request.MfaMethod == ProtectedDataGrantMfaMethods.None)) + throw new ArgumentException("Only an exempt grant has no MFA method, and an exempt grant has none.", nameof(request)); + if (!request.StepUpExempt && request.MfaAtUtc == default) + throw new ArgumentException("A verified grant carries the real verification time; it never defaults to now.", nameof(request)); + if (RequiresEvidenceReference(request.MfaMethod) && + (string.IsNullOrWhiteSpace(request.MfaCredentialId) || request.MfaStateVersion is not >= 0)) + throw new ArgumentException("This MFA method needs its credential or evidence reference and state version.", nameof(request)); + if (request.StepUpExempt && (request.MfaCredentialId != null || request.MfaStateVersion != null)) + throw new ArgumentException("An exempt grant carries no MFA evidence reference.", nameof(request)); + if (request.SessionLockVersion is < 0) + throw new ArgumentException("A lock version cannot be negative.", nameof(request)); + } + + private static ProtectedDataGrantIssueResult IssueVersionTwo(ProtectedDataGrantIssueRequest request, + X509Certificate2 signingCertificate, DateTime now, DateTime expires, string grantId) + { + var skew = TimeSpan.FromSeconds(Math.Max(0, Config.DataProtectionConfig.GrantClockSkewSeconds)); + if (!request.StepUpExempt) + { + if (request.MfaAtUtc > now.Add(skew)) + throw new ArgumentException("The MFA verification time cannot be in the future.", nameof(request)); + + // The window runs from the verification, not from issuance: reusing older evidence (plan section 9.1) + // never extends it, and evidence older than the whole window cannot mint a grant. + var evidenceExpiry = request.MfaAtUtc.Add(expires - now); + if (evidenceExpiry <= now) + throw new ArgumentException("The MFA verification is older than the grant window.", nameof(request)); + if (evidenceExpiry < expires) + expires = evidenceExpiry; + } + + // No grant outlives the session or shift it is bound to (plan section 9.2), and none is issued at or past it. + if (request.NotAfterUtc is DateTime notAfter) + { + notAfter = DateTime.SpecifyKind(notAfter, DateTimeKind.Utc); + if (notAfter <= now) + throw new ArgumentException("The session the grant would be bound to has already ended.", nameof(request)); + if (notAfter < expires) + expires = notAfter; + } + + var ecdsa = signingCertificate.GetECDsaPrivateKey(); + if (ecdsa == null) + throw new InvalidOperationException("The grant signing certificate does not carry an ECDSA private key (ES256 is required)."); + + var credentials = new SigningCredentials(new ECDsaSecurityKey(ecdsa), SecurityAlgorithms.EcdsaSha256); + var token = new JwtSecurityToken( + issuer: Config.DataProtectionConfig.GrantIssuer, + audience: Config.DataProtectionConfig.GrantAudience, + claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, request.UserId) }, + notBefore: now, + expires: expires, + signingCredentials: credentials); + + // Typed payload values: numbers stay JSON numbers and step_up_exempt stays a JSON boolean, so a reader never + // has to guess at a string's meaning. + var payload = token.Payload; + payload[JwtRegisteredClaimNames.Iat] = ToUnixSeconds(now); + payload[JwtRegisteredClaimNames.Jti] = grantId; + payload[VersionClaim] = 2; + payload[DepartmentClaim] = request.DepartmentId; + payload[Model.Security.SessionClaimTypes.SessionId] = request.SessionId; + payload[ClientAppClaim] = request.ClientApp; + payload[PolicyEpochClaim] = request.PolicyEpoch; + payload[AuthenticationGenerationClaim] = request.AuthenticationGeneration.Value; + payload[ScopeClaim] = string.Join(" ", request.Scopes); + payload[MfaMethodClaim] = request.MfaMethod; + payload[StepUpExemptClaim] = request.StepUpExempt; + + var amr = new List { request.FederatedFirstFactor ? "fed" : "pwd" }; + if (!request.StepUpExempt) + { + payload[MfaAtClaim] = ToUnixSeconds(request.MfaAtUtc); + if (request.MfaMethod == ProtectedDataGrantMfaMethods.Totp) + amr.Add("otp"); + amr.Add("mfa"); + } + payload[AmrClaim] = amr; + + if (request.MfaCredentialId != null) + payload[MfaCredentialClaim] = request.MfaCredentialId; + if (request.MfaStateVersion != null) + payload[MfaStateVersionClaim] = request.MfaStateVersion.Value; + if (request.SessionLockVersion != null) + payload[SessionLockVersionClaim] = request.SessionLockVersion.Value; + + return new ProtectedDataGrantIssueResult + { + GrantId = grantId, + Token = TokenHandler.WriteToken(token), + ExpiresOnUtc = expires + }; + } + + /// + /// Structural rules of a version 2 grant (workbook section 7.5). Everything required must be present with the right + /// type, and the MFA facts must be consistent with each other; the caller binding (session, client, generation, lock + /// version) is checked separately against the validated session by ProtectedGrantBinding. + /// + private static bool TryReadVersionTwo(JwtSecurityToken token, DateTime now, TimeSpan skew, out ProtectedDataGrant grant) + { + grant = null; + var payload = token.Payload; + + if (string.IsNullOrWhiteSpace(token.Id) || token.IssuedAt == DateTime.MinValue || token.ValidFrom == DateTime.MinValue || + token.IssuedAt > now.Add(skew)) + return false; + + if (!TryGetString(payload, Model.Security.SessionClaimTypes.SessionId, out var sessionId) || + !TryGetInt64(payload, ClientAppClaim, out var clientApp) || !IsGrantClient(clientApp) || + !TryGetInt64(payload, AuthenticationGenerationClaim, out var generation) || generation < 0 || + !TryGetString(payload, MfaMethodClaim, out var method) || !ProtectedDataGrantMfaMethods.IsKnown(method) || + !TryGetBoolean(payload, StepUpExemptClaim, out var exempt)) + return false; + + if (exempt != (method == ProtectedDataGrantMfaMethods.None)) + return false; + + var mfaAt = default(DateTime); + if (exempt) + { + if (payload.ContainsKey(MfaAtClaim) || payload.ContainsKey(MfaCredentialClaim) || payload.ContainsKey(MfaStateVersionClaim)) + return false; + } + else + { + // The verification happened no later than issuance, and never in the future. + if (!TryGetInt64(payload, MfaAtClaim, out var mfaAtSeconds) || mfaAtSeconds <= 0) + return false; + mfaAt = DateTimeOffset.FromUnixTimeSeconds(mfaAtSeconds).UtcDateTime; + if (mfaAt > token.IssuedAt.Add(skew) || mfaAt > now.Add(skew)) + return false; + } + + string credentialId = null; + long? stateVersion = null; + var hasCredential = payload.ContainsKey(MfaCredentialClaim); + var hasStateVersion = payload.ContainsKey(MfaStateVersionClaim); + if (hasCredential != hasStateVersion || RequiresEvidenceReference(method) && !hasCredential) + return false; + if (hasCredential) + { + if (!TryGetString(payload, MfaCredentialClaim, out credentialId) || + !TryGetInt64(payload, MfaStateVersionClaim, out var parsedStateVersion) || parsedStateVersion < 0) + return false; + stateVersion = parsedStateVersion; + } + + long? lockVersion = null; + if (payload.ContainsKey(SessionLockVersionClaim)) + { + if (!TryGetInt64(payload, SessionLockVersionClaim, out var parsedLockVersion) || parsedLockVersion < 0) + return false; + lockVersion = parsedLockVersion; + } + + var amr = RawStrings(token, AmrClaim); + if (amr.Count == 0 || amr.Any(value => !VersionTwoAmr.Contains(value)) || amr.Distinct(StringComparer.Ordinal).Count() != amr.Count || + amr.Count(value => value == "pwd" || value == "fed") != 1 || + amr.Contains("mfa") == exempt || + amr.Contains("otp") != (method == ProtectedDataGrantMfaMethods.Totp)) + return false; + + grant = new ProtectedDataGrant + { + Version = 2, + SessionId = sessionId, + ClientApp = (int)clientApp, + AuthenticationGeneration = generation, + MfaMethod = method, + StepUpExempt = exempt, + MfaAtUtc = mfaAt, + MfaCredentialId = credentialId, + MfaStateVersion = stateVersion, + SessionLockVersion = lockVersion, + Amr = amr + }; + return true; + } + + private static bool RequiresEvidenceReference(string method) => + method == ProtectedDataGrantMfaMethods.Passkey || method == ProtectedDataGrantMfaMethods.PasskeyApproval || + method == ProtectedDataGrantMfaMethods.Federated; + + private static bool IsGrantClient(long clientApp) => + clientApp > (int)UserSessionClientApplication.UnknownLegacy && Enum.IsDefined(typeof(UserSessionClientApplication), (int)clientApp); + + private static bool TryGetInt64(JwtPayload payload, string name, out long value) + { + value = 0; + if (!payload.TryGetValue(name, out var raw)) + return false; + + switch (raw) + { + case int i: value = i; return true; + case long l: value = l; return true; + case System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Number } element: return element.TryGetInt64(out value); + default: return false; + } + } + + private static bool TryGetBoolean(JwtPayload payload, string name, out bool value) + { + value = false; + if (!payload.TryGetValue(name, out var raw)) + return false; + + switch (raw) + { + case bool b: value = b; return true; + case System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.True }: value = true; return true; + case System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.False }: value = false; return true; + default: return false; + } + } + + private static bool TryGetString(JwtPayload payload, string name, out string value) + { + value = payload.TryGetValue(name, out var raw) ? raw as string : null; + return !string.IsNullOrWhiteSpace(value); + } + + // The token's own claim list is unmapped (no inbound claim-type translation), and a JSON array becomes one claim + // per element. + private static List RawStrings(JwtSecurityToken token, string type) => + token.Claims.Where(claim => claim.Type == type).Select(claim => claim.Value).ToList(); + private X509Certificate2 GetSigningCertificate() => _signingCertificate.Value; private X509Certificate2 GetValidationCertificate() => _validationCertificate.Value; diff --git a/Core/Resgrid.Services/ProtectedGrantBinding.cs b/Core/Resgrid.Services/ProtectedGrantBinding.cs new file mode 100644 index 000000000..3bbaeba6c --- /dev/null +++ b/Core/Resgrid.Services/ProtectedGrantBinding.cs @@ -0,0 +1,149 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// The authoritative-context half of Protected Data Grant checking (passkey plan sections 8.2-8.3). The grant service + /// proves a token is genuine, current and scoped; this proves it belongs to the caller presenting it. Every protected + /// read, write, reveal and sensitive command needs both. + /// + /// A version 1 grant is bound by user only, as before (bounded legacy reading), and is refused on a shared session. A + /// version 2 grant must also match the validated session exactly: session id, client application, authentication + /// generation and lock version. It must not outlive its verification by more than the department's current window. A + /// passkey, Responder approval or provider step-up grant also names a credential whose revocation state + /// checks; the synchronous cannot, so it refuses them. + /// + public static class ProtectedGrantBinding + { + /// + /// The full binding, including the revocation state of the credential behind a passkey, approval or provider step-up + /// grant (plan section 8.2). Without a credential-state service those grants fail closed. + /// + public static async Task CheckAsync(ProtectedDataGrant grant, string callerUserId, + ProtectedGrantSessionContext callerSession, int? policyWindowMinutes, IMfaCredentialStateService credentialStates, + CancellationToken cancellationToken = default) + { + var outcome = CheckContext(grant, callerUserId, callerSession, policyWindowMinutes); + if (outcome != ProtectedGrantBindingOutcome.Bound || !NamesCredential(grant)) + return outcome; + + if (credentialStates == null) + return ProtectedGrantBindingOutcome.MethodUnverifiable; + + try + { + return await credentialStates.IsCurrentAsync(grant, cancellationToken) + ? ProtectedGrantBindingOutcome.Bound + : ProtectedGrantBindingOutcome.MethodUnverifiable; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // A revocation that cannot be checked is not proof that none happened. + Framework.Logging.LogException(ex, "Protected Data Grant credential state could not be read; the grant was refused."); + return ProtectedGrantBindingOutcome.MethodUnverifiable; + } + } + + /// + /// The binding without credential state: TOTP and exempt grants are bound; a passkey, approval or provider step-up + /// grant is refused, because this overload cannot check its credential's revocation. Use . + /// + public static ProtectedGrantBindingOutcome Check(ProtectedDataGrant grant, string callerUserId, + ProtectedGrantSessionContext callerSession, int? policyWindowMinutes = null) + { + var outcome = CheckContext(grant, callerUserId, callerSession, policyWindowMinutes); + return outcome == ProtectedGrantBindingOutcome.Bound && NamesCredential(grant) ? ProtectedGrantBindingOutcome.MethodUnverifiable : outcome; + } + + /// A version 2 grant whose second factor was a credential with its own revocation state. + private static bool NamesCredential(ProtectedDataGrant grant) => + grant.Version >= 2 && grant.MfaMethod != ProtectedDataGrantMfaMethods.Totp && grant.MfaMethod != ProtectedDataGrantMfaMethods.None; + + private static ProtectedGrantBindingOutcome CheckContext(ProtectedDataGrant grant, string callerUserId, + ProtectedGrantSessionContext callerSession, int? policyWindowMinutes) + { + if (grant == null || string.IsNullOrWhiteSpace(callerUserId) || + !string.Equals(grant.UserId, callerUserId, StringComparison.OrdinalIgnoreCase)) + return ProtectedGrantBindingOutcome.UserMismatch; + + // A shared session's grants must prove they were issued since its last lock (plan section 12.5.3). A version 1 + // grant carries no session or lock version, so it cannot, and a shared caller refuses it. + if (grant.Version < 2) + return callerSession?.SessionLockVersion != null ? ProtectedGrantBindingOutcome.SessionLocked : ProtectedGrantBindingOutcome.Bound; + + if (callerSession == null || string.IsNullOrWhiteSpace(callerSession.SessionId) || + !string.Equals(grant.SessionId, callerSession.SessionId, StringComparison.Ordinal)) + return ProtectedGrantBindingOutcome.SessionMismatch; + + if (grant.ClientApp != callerSession.ClientApplication) + return ProtectedGrantBindingOutcome.ClientMismatch; + + if (grant.AuthenticationGeneration != callerSession.AuthenticationGeneration) + return ProtectedGrantBindingOutcome.GenerationMismatch; + + if (grant.SessionLockVersion != callerSession.SessionLockVersion) + return ProtectedGrantBindingOutcome.SessionLocked; + + // TOTP evidence is revoked by advancing the authentication generation (checked above), and an exempt grant has + // no evidence. A grant naming a credential is checked for that credential's revocation by the caller. + if (!ProtectedDataGrantMfaMethods.IsKnown(grant.MfaMethod)) + return ProtectedGrantBindingOutcome.MethodUnverifiable; + + if (policyWindowMinutes.HasValue) + { + var anchor = grant.StepUpExempt ? grant.IssuedAtUtc : grant.MfaAtUtc; + var skew = TimeSpan.FromSeconds(Math.Max(0, Config.DataProtectionConfig.GrantClockSkewSeconds)); + if (grant.ExpiresOnUtc > anchor.AddMinutes(EffectiveWindowMinutes(policyWindowMinutes.Value)).Add(skew)) + return ProtectedGrantBindingOutcome.WindowExceeded; + } + + return ProtectedGrantBindingOutcome.Bound; + } + + /// The department step-up window as issuers apply it: the policy value or the default, clamped to 1..max. + public static int EffectiveWindowMinutes(int policyWindowMinutes) + { + var window = policyWindowMinutes > 0 ? policyWindowMinutes : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + return Math.Min(Math.Max(1, window), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + } + + /// + /// The validated session of the caller, when the grant context describes the same attended user; otherwise null, + /// which makes every version 2 grant fail closed. + /// + public static ProtectedGrantSessionContext SessionFor(IProtectedGrantContext context, string userId) + { + if (context == null || context.IsWorkloadCaller || string.IsNullOrWhiteSpace(userId) || + !string.Equals(context.UserId, userId, StringComparison.OrdinalIgnoreCase)) + return null; + + return context.Session; + } + + /// The value-free error code for a binding failure (workbook section 7.5); null when bound. + public static string ErrorCode(ProtectedGrantBindingOutcome outcome) => outcome switch + { + ProtectedGrantBindingOutcome.Bound => null, + ProtectedGrantBindingOutcome.UserMismatch => "protected_access_denied", + ProtectedGrantBindingOutcome.SessionMismatch => "grant_session_mismatch", + ProtectedGrantBindingOutcome.ClientMismatch => "grant_client_mismatch", + ProtectedGrantBindingOutcome.SessionLocked => "grant_session_locked", + _ => "grant_revoked" + }; + + /// The value-free error code for a validation failure, shared by the application-tier readers. + public static string ErrorCode(ProtectedDataGrantValidationOutcome outcome) => outcome switch + { + ProtectedDataGrantValidationOutcome.Valid => null, + ProtectedDataGrantValidationOutcome.Expired => "grant_expired", + ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", + ProtectedDataGrantValidationOutcome.VersionUnsupported => "grant_version_unsupported", + _ => "step_up_required" + }; + } +} diff --git a/Core/Resgrid.Services/ProtectedGrantIssueRequests.cs b/Core/Resgrid.Services/ProtectedGrantIssueRequests.cs new file mode 100644 index 000000000..602cf17c6 --- /dev/null +++ b/Core/Resgrid.Services/ProtectedGrantIssueRequests.cs @@ -0,0 +1,38 @@ +using System; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Security; + +namespace Resgrid.Services +{ + /// + /// Chooses the grant contract version an issuer emits (passkey plan section 8.2). Every reader accepts version 2 since + /// slice 3; an issuer emits it only once PasskeyConfig.EmitGrantV2 is on, and only for a caller whose session + /// was validated on this request, whose facts it then binds. Otherwise the request stays version 1, as before. + /// + public static class ProtectedGrantIssueRequests + { + public static ProtectedDataGrantIssueRequest ForSession(ProtectedDataGrantIssueRequest request, ProtectedGrantSessionContext session, + string mfaMethod) => ForSession(request, session, mfaMethod, PasskeyConfig.EmitGrantV2); + + public static ProtectedDataGrantIssueRequest ForSession(ProtectedDataGrantIssueRequest request, ProtectedGrantSessionContext session, + string mfaMethod, bool emitVersionTwo) + { + ArgumentNullException.ThrowIfNull(request); + if (!emitVersionTwo || session == null || string.IsNullOrWhiteSpace(session.SessionId)) + return request; + + // The binding comes from the validated session, never from token claims or the client. + request.Version = 2; + request.SessionId = session.SessionId; + request.ClientApp = session.ClientApplication; + request.AuthenticationGeneration = session.AuthenticationGeneration; + request.SessionLockVersion = session.SessionLockVersion; + request.FederatedFirstFactor = session.FederatedFirstFactor; + request.MfaMethod = request.StepUpExempt ? ProtectedDataGrantMfaMethods.None : mfaMethod; + if (request.StepUpExempt) + request.MfaAtUtc = default; + return request; + } + } +} diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 295211732..043969304 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -428,15 +428,36 @@ private sealed class Slot private readonly IProtectedDataGrantService _grantService; private readonly IProtectedDataBrokerClient _brokerClient; private readonly IProtectedFieldCatalog _fieldCatalog; + private readonly IProtectedGrantContext _grantContext; + private readonly IMfaCredentialStateService _credentialStates; public ProtectedReadService(IDepartmentDataProtectionService dataProtectionService, IProtectedDataGrantService grantService, IProtectedDataBrokerClient brokerClient, - IProtectedFieldCatalog fieldCatalog) + IProtectedFieldCatalog fieldCatalog, IProtectedGrantContext grantContext = null, IMfaCredentialStateService credentialStates = null) { + _credentialStates = credentialStates; _dataProtectionService = dataProtectionService; _grantService = grantService; _brokerClient = brokerClient; _fieldCatalog = fieldCatalog; + _grantContext = grantContext; + } + + /// + /// Validates the grant and binds it to the caller (passkey plan section 8.3): null when the caller may act under + /// it, otherwise the value-free error code. A version 2 grant also has to match the request's validated session, + /// which comes from the grant context, never from the token or the client. + /// + private async Task AuthorizeGrantAsync(string grantToken, int departmentId, DepartmentDataProtectionPolicy policy, + string requiredScope, string userId) + { + var outcome = _grantService.ValidateGrant(grantToken, departmentId, policy?.PolicyEpoch ?? 0, + requiredScope, out var grant); + if (outcome != ProtectedDataGrantValidationOutcome.Valid) + return ProtectedGrantBinding.ErrorCode(outcome); + + return ProtectedGrantBinding.ErrorCode(await ProtectedGrantBinding.CheckAsync(grant, userId, + ProtectedGrantBinding.SessionFor(_grantContext, userId), policy?.StepUpWindowMinutes, _credentialStates)); } public async Task ResolveForReadAsync(int departmentId, Call call, @@ -1722,17 +1743,9 @@ public async Task PreflightWriteAsync(int departmentId, st return ProtectedWriteResult.Blocked("step_up_required"); var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); - var outcome = _grantService.ValidateGrant(grantToken, departmentId, policy?.PolicyEpoch ?? 0, - ProtectedDataGrantScopes.Write, out var grant); - if (outcome != ProtectedDataGrantValidationOutcome.Valid) - return ProtectedWriteResult.Blocked(outcome switch - { - ProtectedDataGrantValidationOutcome.Expired => "grant_expired", - ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", - _ => "step_up_required" - }); - if (!string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase)) - return ProtectedWriteResult.Blocked("protected_access_denied"); + var refusal = await AuthorizeGrantAsync(grantToken, departmentId, policy, ProtectedDataGrantScopes.Write, userId); + if (refusal != null) + return ProtectedWriteResult.Blocked(refusal); return ProtectedWriteResult.Allowed(isProtected: true); } @@ -2298,20 +2311,12 @@ private async Task EncryptSlotsAsync(int departmentId, str // 3.3). Workload callers use the broker's encrypt-only lane — no grant, no disclosure. if (!workloadCaller) { - var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); - var outcome = _grantService.ValidateGrant(grantToken, departmentId, policy?.PolicyEpoch ?? 0, - ProtectedDataGrantScopes.Write, out var grant); if (string.IsNullOrWhiteSpace(grantToken)) return ProtectedWriteResult.Blocked("step_up_required"); - if (outcome != ProtectedDataGrantValidationOutcome.Valid) - return ProtectedWriteResult.Blocked(outcome switch - { - ProtectedDataGrantValidationOutcome.Expired => "grant_expired", - ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", - _ => "step_up_required" - }); - if (!string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase)) - return ProtectedWriteResult.Blocked("protected_access_denied"); + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var refusal = await AuthorizeGrantAsync(grantToken, departmentId, policy, ProtectedDataGrantScopes.Write, userId); + if (refusal != null) + return ProtectedWriteResult.Blocked(refusal); } var policyRow = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); @@ -2800,12 +2805,11 @@ private async Task ResolveSlotsAsync(int departmentId, string grantToken, string return; var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); - var currentEpoch = policy?.PolicyEpoch ?? 0; var catalogVersion = policy?.CatalogVersion ?? 0; - // One grant validation per batch, bound to this user and department at the current - // policy epoch. Anything but Valid redacts with a machine-readable reason the clients - // map onto the step-up flow. + // One grant validation per batch, bound to this user (and, for version 2, this session) and + // department at the current policy epoch. Anything but a bound, valid grant redacts with a + // machine-readable reason the clients map onto the step-up flow. string redactionReason; if (string.IsNullOrWhiteSpace(grantToken)) { @@ -2813,17 +2817,7 @@ private async Task ResolveSlotsAsync(int departmentId, string grantToken, string } else { - var outcome = _grantService.ValidateGrant(grantToken, departmentId, currentEpoch, - ProtectedDataGrantScopes.Read, out var grant); - redactionReason = outcome switch - { - ProtectedDataGrantValidationOutcome.Valid when - string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase) => null, - ProtectedDataGrantValidationOutcome.Valid => "protected_access_denied", - ProtectedDataGrantValidationOutcome.Expired => "grant_expired", - ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", - _ => "step_up_required" - }; + redactionReason = await AuthorizeGrantAsync(grantToken, departmentId, policy, ProtectedDataGrantScopes.Read, userId); } if (redactionReason != null) diff --git a/Core/Resgrid.Services/ProtocolsService.cs b/Core/Resgrid.Services/ProtocolsService.cs index f557ae140..f566a3c99 100644 --- a/Core/Resgrid.Services/ProtocolsService.cs +++ b/Core/Resgrid.Services/ProtocolsService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -16,11 +17,13 @@ public class ProtocolsService : IProtocolsService private readonly IDispatchProtocolQuestionsRepository _dispatchProtocolQuestionsRepository; private readonly IDispatchProtocolTriggersRepository _dispatchProtocolTriggersRepository; private readonly IDispatchProtocolQuestionAnswersRepository _dispatchProtocolQuestionAnswersRepository; + private readonly Lazy _searchProjections; public ProtocolsService(IDispatchProtocolRepository dispatchProtocolRepository, IDispatchProtocolAttachmentRepository dispatchProtocolAttachmentRepository, IDispatchProtocolQuestionsRepository dispatchProtocolQuestionsRepository, IDispatchProtocolTriggersRepository dispatchProtocolTriggersRepository, - IDispatchProtocolQuestionAnswersRepository dispatchProtocolQuestionAnswersRepository) + IDispatchProtocolQuestionAnswersRepository dispatchProtocolQuestionAnswersRepository, Lazy searchProjections = null) { + _searchProjections = searchProjections; _dispatchProtocolRepository = dispatchProtocolRepository; _dispatchProtocolAttachmentRepository = dispatchProtocolAttachmentRepository; _dispatchProtocolQuestionsRepository = dispatchProtocolQuestionsRepository; @@ -64,6 +67,7 @@ public async Task> GetAllProtocolsForDepartmentAsync(int } } + if (_searchProjections != null) await _searchProjections.Value.ProjectProtocolAsync(saved, cancellationToken); return saved; } @@ -98,7 +102,10 @@ public async Task GetProtocolByIdAsync(int id) public async Task DeleteProtocol(int id, CancellationToken cancellationToken = default(CancellationToken)) { var procotol = await GetProtocolByIdAsync(id); - return await _dispatchProtocolRepository.DeleteAsync(procotol, cancellationToken); + var deleted = await _dispatchProtocolRepository.DeleteAsync(procotol, cancellationToken); + if (deleted && procotol != null && _searchProjections != null) + await _searchProjections.Value.RemoveAsync(procotol.DepartmentId, SearchEntityTypes.Protocol, procotol.DispatchProtocolId.ToString(), cancellationToken); + return deleted; } public List ProcessTriggers(List protocols, Call call) diff --git a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs index 0f4323764..0468e417e 100644 --- a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs +++ b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs @@ -7,6 +7,7 @@ using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services.Records @@ -45,13 +46,16 @@ public class RecordsOccupancyService : IRecordsOccupancyService, IContactPreplan private readonly IProtectedGrantContext _grant; private readonly IRecordsProtectionService _protection; private readonly IUnitOfWork _unitOfWork; + private readonly Lazy _searchProjections; public RecordsOccupancyService(RecordsPreventionGate gate, IRmsOccupanciesRepository occupancies, IRmsOccupancyContactLinksRepository links, IRmsOccupancyHazardsRepository hazards, IRmsOccupancyCrosswalksRepository crosswalks, IRmsOccupancyFieldProvenancesRepository provenance, IRmsOccupancyOwnershipsRepository ownerships, IRmsViolationsRepository violations, IRmsHydrantsRepository hydrants, IContactPreplanRepository contactPreplans, IContactPreplanHazardRepository contactHazards, IContactsRepository contacts, IAddressRepository addresses, - IPoisRepository pois, IPoiTypesRepository poiTypes, IProtectedReadService protectedReads, IProtectedGrantContext grant, IRecordsProtectionService protection, IUnitOfWork unitOfWork) + IPoisRepository pois, IPoiTypesRepository poiTypes, IProtectedReadService protectedReads, IProtectedGrantContext grant, IRecordsProtectionService protection, IUnitOfWork unitOfWork, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _gate = gate; _occupancies = occupancies; _links = links; @@ -172,9 +176,17 @@ await _provenance.InsertAsync(new RmsOccupancyFieldProvenance } catch { _unitOfWork.DiscardChanges(); throw; } plaintext.Restore(); + await ReprojectAsync(departmentId, entity.RmsOccupancyId, cancellationToken); return entity; } + /// Re-reads the occupancy and rewrites its search projection (removed or merged ones leave the index). + private async Task ReprojectAsync(int departmentId, string occupancyId, CancellationToken cancellationToken) + { + if (_searchProjections != null && !string.IsNullOrWhiteSpace(occupancyId)) + await _searchProjections.Value.RefreshAsync(departmentId, SearchEntityTypes.Occupancy, occupancyId, cancellationToken); + } + public async Task DeleteAsync(int departmentId, string userId, string occupancyId, CancellationToken cancellationToken = default) { await _gate.RequireEnabledAsync(departmentId, RecordsPreventionModule.Occupancy); @@ -186,6 +198,7 @@ public async Task DeleteAsync(int departmentId, string userId, string occupancyI occupancy.DeletedOn = DateTime.UtcNow; occupancy.ModifiedOn = occupancy.DeletedOn.Value; occupancy.ModifiedByUserId = userId; occupancy.RowVersion++; await _occupancies.UpdateAsync(occupancy, cancellationToken, true); await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy removed", occupancyId, new { occupancy.OccupancyNumber }, cancellationToken: cancellationToken); + await ReprojectAsync(departmentId, occupancyId, cancellationToken); } public async Task MarkReviewedAsync(int departmentId, string userId, string occupancyId, int nextReviewMonths, CancellationToken cancellationToken = default) @@ -302,6 +315,7 @@ public async Task SaveHazardAsync(int departmentId, string u if (existing == null) await _hazards.InsertAsync(entity, cancellationToken, true); else await _hazards.UpdateAsync(entity, cancellationToken, true); plaintext.Restore(); await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, existing == null ? "Occupancy hazard added" : "Occupancy hazard updated", occupancy.RmsOccupancyId, new { entity.RmsOccupancyHazardId, entity.Severity, entity.ShouldAlert }, cancellationToken: cancellationToken); + await ReprojectAsync(departmentId, occupancy.RmsOccupancyId, cancellationToken); return entity; } @@ -314,6 +328,7 @@ public async Task DeleteHazardAsync(int departmentId, string userId, string haza hazard.DeletedOn = DateTime.UtcNow; hazard.ModifiedOn = hazard.DeletedOn.Value; hazard.RowVersion++; await _hazards.UpdateAsync(hazard, cancellationToken, true); await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Occupancy hazard removed", hazard.RmsOccupancyId, new { hazardId }, cancellationToken: cancellationToken); + await ReprojectAsync(departmentId, hazard.RmsOccupancyId, cancellationToken); } public async Task LinkContactAsync(int departmentId, string userId, string occupancyId, string contactId, RmsOccupancyContactRole role, bool isPrimary, CancellationToken cancellationToken = default) @@ -564,6 +579,7 @@ public async Task LinkCandidateAsync(int departmentId, string user _unitOfWork.CommitChanges(); } catch { _unitOfWork.DiscardChanges(); throw; } + await ReprojectAsync(departmentId, occupancy.RmsOccupancyId, cancellationToken); return occupancy; } @@ -696,6 +712,9 @@ public async Task MergeAsync(int departmentId, string userId, stri _unitOfWork.CommitChanges(); } catch { _unitOfWork.DiscardChanges(); throw; } + // The merged source leaves the index; the survivor picks up the source's hazards. + await ReprojectAsync(departmentId, sourceOccupancyId, cancellationToken); + await ReprojectAsync(departmentId, targetOccupancyId, cancellationToken); return target; } diff --git a/Core/Resgrid.Services/RelyingPartyRegistry.cs b/Core/Resgrid.Services/RelyingPartyRegistry.cs new file mode 100644 index 000000000..5dcb52ff0 --- /dev/null +++ b/Core/Resgrid.Services/RelyingPartyRegistry.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Parses and validates PasskeyConfig.RelyingParties (workbook section 5). The rules that keep each passkey bound to + /// its own app are enforced here, at startup: every client has its own RP ID, no RP ID is the parent domain of another, + /// and every origin is an exact https origin under its RP ID or an Android signing-certificate origin. Any problem makes + /// the whole configuration not ready, which turns every passkey gate off. + /// + public sealed class RelyingPartyRegistry : IRelyingPartyRegistry + { + private static readonly Dictionary ClientNames = new(StringComparer.OrdinalIgnoreCase) + { + ["web"] = UserSessionClientApplication.Web, + ["responder"] = UserSessionClientApplication.Responder, + ["unit"] = UserSessionClientApplication.Unit, + ["dispatch"] = UserSessionClientApplication.Dispatch, + ["command"] = UserSessionClientApplication.Command, + ["ic"] = UserSessionClientApplication.Command + }; + + private static readonly Regex HostPattern = new(@"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", RegexOptions.Compiled); + private static readonly Regex AndroidOriginPattern = new(@"^android:apk-key-hash:[A-Za-z0-9_-]{43}$", RegexOptions.Compiled); + + private readonly Dictionary _parties; + + public RelyingPartyRegistry() : this(PasskeyConfig.RelyingParties) + { + } + + public RelyingPartyRegistry(string configuration) + { + var (parties, problems) = Parse(configuration); + Readiness = new PasskeyReadiness { IsReady = problems.Count == 0 && parties.Count > 0, Problems = problems }; + _parties = Readiness.IsReady ? parties : new Dictionary(); + } + + public PasskeyReadiness Readiness { get; } + + public RelyingPartyDescriptor Get(UserSessionClientApplication client) + => _parties.TryGetValue(client, out var party) ? party : null; + + private static (Dictionary Parties, List Problems) Parse(string configuration) + { + var parties = new Dictionary(); + var problems = new List(); + + if (string.IsNullOrWhiteSpace(configuration)) + { + problems.Add("No relying parties are configured; passkeys are unavailable on this deployment."); + return (parties, problems); + } + + foreach (var rawEntry in configuration.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var equals = rawEntry.IndexOf('='); + var pipe = rawEntry.IndexOf('|'); + if (equals <= 0 || pipe <= equals + 1) + { + problems.Add("A relying-party entry is not in the form client=rpId|origin,origin."); + continue; + } + + var clientName = rawEntry[..equals].Trim(); + if (!ClientNames.TryGetValue(clientName, out var client)) + { + problems.Add($"Relying-party entry names an unknown client '{clientName}'."); + continue; + } + + if (parties.ContainsKey(client)) + { + problems.Add($"Client '{clientName}' has more than one relying-party entry."); + continue; + } + + var rpId = rawEntry[(equals + 1)..pipe].Trim().ToLowerInvariant(); + if (!HostPattern.IsMatch(rpId)) + { + problems.Add($"Client '{clientName}' has an RP ID that is not a valid host name."); + continue; + } + + var origins = rawEntry[(pipe + 1)..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (origins.Length == 0) + { + problems.Add($"Client '{clientName}' has no allowed origins."); + continue; + } + + var badOrigin = origins.FirstOrDefault(origin => !IsAllowedOrigin(origin, rpId)); + if (badOrigin != null) + { + problems.Add($"Client '{clientName}' lists an origin that is not an https origin under its RP ID or an Android apk-key-hash origin."); + continue; + } + + parties[client] = new RelyingPartyDescriptor + { + ClientApplication = client, + RpId = rpId, + Origins = origins.Select(NormalizeOrigin).Distinct(StringComparer.Ordinal).ToArray() + }; + } + + // One RP per client, and no RP may be a parent domain of another: a parent-domain credential could be exercised + // from every sub-host, which would let one app use another app's passkeys (plan section 1.1 item 11). + var all = parties.Values.ToList(); + foreach (var party in all) + { + foreach (var other in all.Where(o => o.ClientApplication != party.ClientApplication)) + { + if (string.Equals(party.RpId, other.RpId, StringComparison.Ordinal)) + problems.Add($"Clients {party.ClientApplication} and {other.ClientApplication} share an RP ID; each client needs its own."); + else if (other.RpId.EndsWith("." + party.RpId, StringComparison.Ordinal)) + problems.Add($"The RP ID of {party.ClientApplication} is a parent domain of {other.ClientApplication}'s RP ID."); + } + } + + return (parties, problems.Distinct().ToList()); + } + + private static bool IsAllowedOrigin(string origin, string rpId) + { + if (AndroidOriginPattern.IsMatch(origin)) + return true; + + // An origin is scheme, host and optional port only: no path, query, fragment or credentials. + if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) || uri.PathAndQuery != "/" || !string.IsNullOrEmpty(uri.Fragment) + || !string.IsNullOrEmpty(uri.UserInfo)) + return false; + + var host = uri.Host.ToLowerInvariant(); + var underRp = host == rpId || host.EndsWith("." + rpId, StringComparison.Ordinal); + if (!underRp) + return false; + + // Plain http only for a local development relying party. + return uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && rpId == "localhost"); + } + + private static string NormalizeOrigin(string origin) + { + if (origin.StartsWith("android:", StringComparison.Ordinal)) + return origin; + + var uri = new Uri(origin); + return uri.IsDefaultPort ? $"{uri.Scheme}://{uri.Host.ToLowerInvariant()}" : $"{uri.Scheme}://{uri.Host.ToLowerInvariant()}:{uri.Port}"; + } + } + + /// + public sealed class PasskeyFeatureGates : IPasskeyFeatureGates + { + private readonly IRelyingPartyRegistry _registry; + + public PasskeyFeatureGates(IRelyingPartyRegistry registry) + { + _registry = registry; + } + + private bool Ready => _registry.Readiness.IsReady; + + public bool RegistrationEnabled => Ready && PasskeyConfig.RegistrationEnabled; + public bool LoginAcceptanceEnabled => Ready && PasskeyConfig.LoginAcceptanceEnabled; + public bool AdpAcceptanceEnabled => Ready && PasskeyConfig.AdpAcceptanceEnabled; + + // Grant v2 and the non-passkey methods do not depend on relying parties. + public bool EmitGrantV2 => PasskeyConfig.EmitGrantV2; + public bool SharedDeviceModeEnabled => PasskeyConfig.SharedDeviceModeEnabled; + public bool ResponderApprovalEnabled => Ready && PasskeyConfig.ResponderApprovalEnabled; + public bool ProviderStepUpEnabled => PasskeyConfig.ProviderStepUpEnabled; + } + + /// + /// Startup report of the passkey configuration (plan section 10.3): value-free, and loud only when a gate is on but + /// the configuration it depends on is not valid, in which case the gate has no effect. + /// + public static class PasskeyReadinessReporter + { + public static void Report(IRelyingPartyRegistry registry) + { + if (registry == null) + return; + + var readiness = registry.Readiness; + var anyRpGateOn = PasskeyConfig.RegistrationEnabled || PasskeyConfig.LoginAcceptanceEnabled + || PasskeyConfig.AdpAcceptanceEnabled || PasskeyConfig.ResponderApprovalEnabled; + + if (readiness.IsReady) + Framework.Logging.LogInfo("Passkey relying-party configuration validated."); + else if (anyRpGateOn) + Framework.Logging.LogError("Passkey gates are enabled but the relying-party configuration is not valid, so passkeys stay OFF: " + + string.Join(" ", readiness.Problems)); + } + } +} diff --git a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs index 06ac11782..8032fa0ac 100644 --- a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs +++ b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs @@ -30,6 +30,14 @@ public class SearchIndexMaintenanceService : ISearchIndexMaintenanceService /// private const int RebuildPageSize = 500; + /// + /// One sweep per process at a time. The scheduler starts a sweep every minute (and retries a failed one) whether or not + /// the previous run has finished. Two sweeps in one worker shared the host's single writer and the publish lease — the + /// lease admits its own owner, so both got in — raced the manifest ETag, and the loser's conflict reset wiped the local + /// index under the winner. An overlapping sweep now skips instead. + /// + private static readonly SemaphoreSlim SweepGate = new SemaphoreSlim(1, 1); + private readonly ISearchIndexStatesRepository _states; private readonly ISearchProjectionsRepository _projections; private readonly IGlobalSearchIndexer _indexer; @@ -50,14 +58,31 @@ public class SearchIndexMaintenanceService : ISearchIndexMaintenanceService private readonly Lazy _contracts; private readonly Lazy _deploymentsService; private readonly Lazy _certifications; + private readonly Lazy _protocols; + private readonly Lazy _trainings; + private readonly Lazy _calendar; + private readonly Lazy _logs; + private readonly Lazy _mapping; + private readonly Lazy _shifts; + private readonly IRmsOccupanciesRepository _occupancies; public SearchIndexMaintenanceService(ISearchIndexStatesRepository states, ISearchProjectionsRepository projections, IGlobalSearchIndexer indexer, IDepartmentDataProtectionService dataProtection, IFeatureToggleService featureToggles, ISearchProjectionService projectionService, ICallsService calls, IUnitsService units, IUserProfileService profiles, IDepartmentsService departments, IDepartmentGroupsService groups, IContactsService contacts, IMessageService messages, IDocumentsService documents, INotesService notes, Lazy invoicing = null, Lazy bids = null, Lazy contracts = null, - Lazy deploymentsService = null, Lazy certifications = null) + Lazy deploymentsService = null, Lazy certifications = null, + Lazy protocols = null, Lazy trainings = null, Lazy calendar = null, + Lazy logs = null, Lazy mapping = null, Lazy shifts = null, + IRmsOccupanciesRepository occupancies = null) { + _occupancies = occupancies; + _protocols = protocols; + _trainings = trainings; + _calendar = calendar; + _logs = logs; + _mapping = mapping; + _shifts = shifts; _invoicing = invoicing; _bids = bids; _contracts = contracts; @@ -90,8 +115,32 @@ public async Task SweepAsync(CancellationToken cancellat return result; } + if (!await SweepGate.WaitAsync(0, cancellationToken)) + { + result.Skipped = true; + result.Message = "The previous global index sweep is still running in this process; skipped."; + return result; + } + + try + { + await SweepCoreAsync(result, cancellationToken); + } + finally + { + SweepGate.Release(); + } + + result.Message = $"Checked {result.DepartmentsChecked} department(s); rebuilt {result.DepartmentsRebuilt} ({result.ProjectionsRebuilt} projections); indexed {result.DocumentsIndexed}; deleted {result.DocumentsDeleted}; errors {result.Errors}."; + return result; + } + + private async Task SweepCoreAsync(SearchIndexSweepResult result, CancellationToken cancellationToken) + { var states = (await _states.GetAllForIndexAsync(SearchIndexNames.Global))?.ToList() ?? new List(); var rebuilds = 0; + // Catch-up checkpoints wait for the sweep's single commit: a checkpoint must never lead the committed segments. + var pending = new List<(SearchIndexState State, DateTime? Checkpoint)>(); foreach (var state in states) { @@ -121,7 +170,9 @@ public async Task SweepAsync(CancellationToken cancellat } else { - await CatchUpAsync(state.DepartmentId, generation, state, result, cancellationToken); + var (changed, checkpoint) = await CatchUpAsync(state.DepartmentId, generation, state, result, cancellationToken); + if (changed) + pending.Add((state, checkpoint)); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -135,8 +186,46 @@ public async Task SweepAsync(CancellationToken cancellat } } - result.Message = $"Checked {result.DepartmentsChecked} department(s); rebuilt {result.DepartmentsRebuilt} ({result.ProjectionsRebuilt} projections); indexed {result.DocumentsIndexed}; deleted {result.DocumentsDeleted}; errors {result.Errors}."; - return result; + if (pending.Count == 0) + return; + + try + { + // One commit-and-publish per sweep, not one per department: each publish lists the bucket, uploads the new + // segments and conditionally replaces the manifest, so a per-department commit made every quiet minute with N + // activated departments cost N publishes. + await _indexer.CommitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + result.Errors++; + Logging.LogException(ex, $"Global search index commit failed; {pending.Count} department checkpoint(s) were not advanced and the next sweep re-reads their rows."); + return; + } + + foreach (var (state, checkpoint) in pending) + { + try + { + state.LastIndexedModifiedOn = checkpoint; + state.DocumentCount = await _indexer.CountDocumentsAsync(state.DepartmentId); + state.ModifiedOn = DateTime.UtcNow; + await _states.SaveOrUpdateAsync(state, cancellationToken, true); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + result.Errors++; + Logging.LogException(ex, $"Global search index checkpoint could not be saved for department {state.DepartmentId}; the next sweep re-reads its rows."); + } + } } public async Task RebuildDepartmentAsync(int departmentId, CancellationToken cancellationToken = default) @@ -149,9 +238,18 @@ public async Task RebuildDepartmentAsync(int departmentI return result; } - var generation = await ComputeGenerationAsync(departmentId); - var state = await _states.GetAsync(SearchIndexNames.Global, departmentId); - await RebuildAsync(departmentId, generation, state, result, cancellationToken); + await SweepGate.WaitAsync(cancellationToken); + try + { + var generation = await ComputeGenerationAsync(departmentId); + var state = await _states.GetAsync(SearchIndexNames.Global, departmentId); + await RebuildAsync(departmentId, generation, state, result, cancellationToken); + } + finally + { + SweepGate.Release(); + } + result.Message = $"Rebuilt department {departmentId}: {result.ProjectionsRebuilt} projection(s), {result.DocumentsIndexed} document(s)."; return result; } @@ -222,10 +320,18 @@ private async Task RebuildAsync(int departmentId, string generation, SearchIndex } } - private async Task CatchUpAsync(int departmentId, string generation, SearchIndexState state, SearchIndexSweepResult result, CancellationToken cancellationToken) + /// + /// Writes the rows modified since the department's checkpoint into the index without committing. Returns whether the + /// index changed and the checkpoint to save once the caller's commit has succeeded. + /// + private async Task<(bool Changed, DateTime? Checkpoint)> CatchUpAsync(int departmentId, string generation, SearchIndexState state, SearchIndexSweepResult result, CancellationToken cancellationToken) { - var checkpoint = state.LastIndexedModifiedOn; - var since = checkpoint.HasValue && checkpoint.Value > DateTime.MinValue.AddSeconds(1) ? checkpoint.Value.AddSeconds(-1) : checkpoint; + var stored = state.LastIndexedModifiedOn; + var checkpoint = stored; + // The read re-covers the last second before the checkpoint so a row stamped just before it but committed after + // the previous read is not lost. Rows it re-reads unchanged are skipped below; re-indexing them made every quiet + // sweep commit and publish every department. + var since = stored.HasValue && stored.Value > DateTime.MinValue.AddSeconds(1) ? stored.Value.AddSeconds(-1) : stored; string sinceId = null; var batch = Math.Max(50, Math.Min(5000, SearchConfig.IndexBatchSize)); var touched = false; @@ -237,15 +343,17 @@ private async Task CatchUpAsync(int departmentId, string generation, SearchIndex if (page.Count == 0) break; - var deleted = page.Where(p => p.DeletedOn.HasValue).ToList(); - var live = page.Where(p => !p.DeletedOn.HasValue).ToList(); + var changed = await WithoutUnchangedOverlapAsync(departmentId, page, stored); + var deleted = changed.Where(p => p.DeletedOn.HasValue).ToList(); + var live = changed.Where(p => !p.DeletedOn.HasValue).ToList(); foreach (var gone in deleted) await _indexer.DeleteAsync(departmentId, gone.EntityType, gone.EntityId, cancellationToken); - result.DocumentsIndexed += await _indexer.IndexAsync(live, generation, cancellationToken); + if (live.Count > 0) + result.DocumentsIndexed += await _indexer.IndexAsync(live, generation, cancellationToken); result.DocumentsDeleted += deleted.Count; - touched = true; + touched |= changed.Count > 0; var last = page[page.Count - 1]; if (since.HasValue && (last.ModifiedOn < since.Value || last.ModifiedOn == since.Value && string.Equals(last.SearchProjectionId, sinceId, StringComparison.Ordinal))) @@ -258,14 +366,30 @@ private async Task CatchUpAsync(int departmentId, string generation, SearchIndex break; } - if (touched) + return (touched, checkpoint); + } + + /// + /// Drops the rows of the overlap window (stamped at or before the stored checkpoint) that the index already holds at + /// the same RowVersion, and deleted rows the index no longer holds. Rows past the checkpoint are always kept. + /// + private async Task> WithoutUnchangedOverlapAsync(int departmentId, List page, DateTime? stored) + { + if (!stored.HasValue) + return page; + + var overlap = page.Where(p => p.ModifiedOn <= stored.Value).ToList(); + if (overlap.Count == 0) + return page; + + var indexed = await _indexer.GetIndexedRowVersionsAsync(departmentId, overlap) ?? new Dictionary(); + return page.Where(p => { - await _indexer.CommitAsync(cancellationToken); - state.LastIndexedModifiedOn = checkpoint; - state.DocumentCount = await _indexer.CountDocumentsAsync(departmentId); - state.ModifiedOn = DateTime.UtcNow; - await _states.SaveOrUpdateAsync(state, cancellationToken, true); - } + if (p.ModifiedOn > stored.Value) + return true; + var present = indexed.TryGetValue(p.SearchProjectionId ?? string.Empty, out var version); + return p.DeletedOn.HasValue ? present : !present || version != p.RowVersion; + }).ToList(); } /// Regenerates every projection row of the department from the entity services, then soft-deletes rows no longer present. @@ -466,6 +590,108 @@ private async Task RebuildProjectionsAsync(int departmentId, CancellationTo }, started, cancellationToken); } + // Operations reference families (plan R3 Tier 2). Lazy for the same reason as the Business Operations ones. + count += await Family(departmentId, SearchEntityTypes.Group, async () => + { + var n = 0; + foreach (var group in await _groups.GetAllGroupsForDepartmentUnlimitedAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildGroupAsync(group); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_protocols?.Value != null) + count += await Family(departmentId, SearchEntityTypes.Protocol, async () => + { + var n = 0; + foreach (var protocol in await _protocols.Value.GetAllProtocolsForDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildProtocolAsync(protocol); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_trainings?.Value != null) + count += await Family(departmentId, SearchEntityTypes.Training, async () => + { + var n = 0; + foreach (var training in await _trainings.Value.GetAllTrainingsForDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildTrainingAsync(training); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_calendar?.Value != null) + count += await Family(departmentId, SearchEntityTypes.CalendarEvent, async () => + { + var n = 0; + // Occurrences of a recurring series build to null; the parent row carries the event. + foreach (var item in await _calendar.Value.GetAllCalendarItemsForDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildCalendarItemAsync(item); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_logs?.Value != null) + count += await Family(departmentId, SearchEntityTypes.Log, async () => + { + var n = 0; + foreach (var log in await _logs.Value.GetAllLogsForDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildLogAsync(log); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_mapping?.Value != null) + count += await Family(departmentId, SearchEntityTypes.Poi, async () => + { + var n = 0; + // The department read returns each POI with its type attached; POIs carry their department only through it. + foreach (var poi in await _mapping.Value.GetPOIsForDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildPoiAsync(poi, poi.Type); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + if (_shifts?.Value != null) + count += await Family(departmentId, SearchEntityTypes.Shift, async () => + { + var n = 0; + foreach (var shift in await _shifts.Value.GetAllShiftsByDepartmentAsync(departmentId) ?? new List()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildShiftAsync(shift); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + + if (_occupancies != null) + count += await Family(departmentId, SearchEntityTypes.Occupancy, async () => + { + // Read straight from the repository: the occupancy service's reads are per viewer, and a rebuild has none. + // Removed and merged occupancies build to null. + var n = 0; + foreach (var occupancy in await _occupancies.GetAllLiveAsync(departmentId) ?? Enumerable.Empty()) + { + cancellationToken.ThrowIfCancellationRequested(); + var p = await _projectionService.BuildOccupancyAsync(occupancy); + if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; } + } + return n; + }, started, cancellationToken); + count += await Family(departmentId, SearchEntityTypes.Message, async () => { // One department-scoped read (M0137 owner column) instead of two folder queries per member. A message diff --git a/Core/Resgrid.Services/Search/SearchProjectionService.cs b/Core/Resgrid.Services/Search/SearchProjectionService.cs index 8b5ee8f3d..2d32a64e1 100644 --- a/Core/Resgrid.Services/Search/SearchProjectionService.cs +++ b/Core/Resgrid.Services/Search/SearchProjectionService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -17,9 +18,9 @@ namespace Resgrid.Services.Search /// /// Builds and stores the safe search projection for each Tier 1 entity (plan R3). The allowlist is decided here, per /// family, against the protected-field catalog: a column the catalog protects (call name/nature/address/incident - /// number, every contact field, document name/description/filename, message subject/body, member identification - /// number) is projected only when Advanced Data Protection is not enforced for the department (R2.15, the RMS - /// narrative precedent). A value carrying an envelope prefix or the redaction placeholder is dropped regardless. + /// number/notes and call note text, every contact field, document name/description/filename, message subject/body, + /// member identification number) is projected only when Advanced Data Protection is not enforced for the department + /// (R2.15, the RMS narrative precedent). A value carrying an envelope prefix or the redaction placeholder is dropped regardless. /// Enrollment bumps the generation key, and the rebuild that follows re-projects without those columns. /// public class SearchProjectionService : ISearchProjectionService @@ -28,6 +29,8 @@ public class SearchProjectionService : ISearchProjectionService private const int SummaryMax = 1000; private const int KeywordsMax = 400; private const int SearchTextMax = 8000; + /// Calls carry their dispatch notes and every call note in the full text, so they get more room than other families. + private const int CallSearchTextMax = 32000; private static readonly Regex HtmlTags = new Regex("<[^>]+>", RegexOptions.Compiled); private static readonly Regex Whitespace = new Regex("\\s+", RegexOptions.Compiled); @@ -35,14 +38,73 @@ public class SearchProjectionService : ISearchProjectionService private readonly ISearchProjectionsRepository _projections; private readonly IDepartmentDataProtectionService _dataProtection; private readonly IDeploymentPersonnelRepository _deploymentPersonnel; + private readonly ICallNotesRepository _callNotes; + private readonly IAddressRepository _addresses; + private readonly IContactNotesRepository _contactNotes; + private readonly IContactCategoryRepository _contactCategories; + private readonly IPersonnelRolesRepository _personnelRoles; + private readonly IDepartmentGroupMembersRepository _groupMembers; + private readonly IDepartmentGroupsRepository _groups; + private readonly IDepartmentMemberSensitiveDataRepository _memberSensitiveData; + private readonly IUdfDefinitionRepository _udfDefinitions; + private readonly IUdfFieldRepository _udfFields; + private readonly IUdfFieldValueRepository _udfValues; + private readonly ICallsRepository _calls; + private readonly IUnitsRepository _units; + private readonly IContactsRepository _contacts; + private readonly IUserProfilesRepository _profiles; + private readonly IDepartmentMembersRepository _members; + private readonly IPoiTypesRepository _poiTypes; + private readonly IRmsOccupanciesRepository _occupancies; + private readonly IRmsOccupancyHazardsRepository _occupancyHazards; + + // One scope serves one request or one worker sweep; these keep a department rebuild from re-reading the same group, + // category or custom-field definition for every row. + private readonly ConcurrentDictionary _groupCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _categoryCache = new ConcurrentDictionary(StringComparer.Ordinal); + private readonly ConcurrentDictionary<(int, int), List> _udfFieldCache = new ConcurrentDictionary<(int, int), List>(); + private readonly ConcurrentDictionary<(int, int), string> _udfDefinitionCache = new ConcurrentDictionary<(int, int), string>(); /// Roster rows for the deployment projection's participants; optional only for hosts without the Business Operations repositories. + /// The call's note rows for its full text; without it only the notes already loaded on the call are projected. + /// + /// The remaining repositories enrich a projection with related rows (addresses, notes, group and role names, custom + /// field values, member identification numbers) and back . Each is optional: a host without + /// one projects the entity's own columns only. Repositories, not services, so the services that call the hooks never + /// form a construction cycle with this one. + /// public SearchProjectionService(ISearchProjectionsRepository projections, IDepartmentDataProtectionService dataProtection, - IDeploymentPersonnelRepository deploymentPersonnel = null) + IDeploymentPersonnelRepository deploymentPersonnel = null, ICallNotesRepository callNotes = null, + IAddressRepository addresses = null, IContactNotesRepository contactNotes = null, IContactCategoryRepository contactCategories = null, + IPersonnelRolesRepository personnelRoles = null, IDepartmentGroupMembersRepository groupMembers = null, IDepartmentGroupsRepository groups = null, + IDepartmentMemberSensitiveDataRepository memberSensitiveData = null, IUdfDefinitionRepository udfDefinitions = null, + IUdfFieldRepository udfFields = null, IUdfFieldValueRepository udfValues = null, ICallsRepository calls = null, + IUnitsRepository units = null, IContactsRepository contacts = null, IUserProfilesRepository profiles = null, + IDepartmentMembersRepository members = null, IPoiTypesRepository poiTypes = null, + IRmsOccupanciesRepository occupancies = null, IRmsOccupancyHazardsRepository occupancyHazards = null) { + _occupancies = occupancies; + _occupancyHazards = occupancyHazards; _projections = projections ?? throw new ArgumentNullException(nameof(projections)); _dataProtection = dataProtection ?? throw new ArgumentNullException(nameof(dataProtection)); _deploymentPersonnel = deploymentPersonnel; + _callNotes = callNotes; + _addresses = addresses; + _contactNotes = contactNotes; + _contactCategories = contactCategories; + _personnelRoles = personnelRoles; + _groupMembers = groupMembers; + _groups = groups; + _memberSensitiveData = memberSensitiveData; + _udfDefinitions = udfDefinitions; + _udfFields = udfFields; + _udfValues = udfValues; + _calls = calls; + _units = units; + _contacts = contacts; + _profiles = profiles; + _members = members; + _poiTypes = poiTypes; } // ---- hooks ----------------------------------------------------------------------------------------------- @@ -77,6 +139,147 @@ public Task ProjectDocumentAsync(Document document, CancellationToken cancellati public Task ProjectNoteAsync(Note note, CancellationToken cancellationToken = default) => Guarded(SearchEntityTypes.Note, note?.DepartmentId ?? 0, note?.NoteId.ToString(), false, () => BuildNoteAsync(note), cancellationToken); + public Task ProjectProtocolAsync(DispatchProtocol protocol, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.Protocol, protocol?.DepartmentId ?? 0, protocol?.DispatchProtocolId.ToString(), false, () => BuildProtocolAsync(protocol), cancellationToken); + + public Task ProjectTrainingAsync(Training training, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.Training, training?.DepartmentId ?? 0, training?.TrainingId.ToString(), false, () => BuildTrainingAsync(training), cancellationToken); + + public Task ProjectCalendarItemAsync(CalendarItem item, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.CalendarEvent, item?.DepartmentId ?? 0, item?.CalendarItemId.ToString(), false, () => BuildCalendarItemAsync(item), cancellationToken); + + public Task ProjectLogAsync(Log log, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.Log, log?.DepartmentId ?? 0, log?.LogId.ToString(), false, () => BuildLogAsync(log), cancellationToken); + + public async Task ProjectPoiAsync(Poi poi, CancellationToken cancellationToken = default) + { + if (poi == null || poi.PoiId <= 0) + return; + PoiType type = poi.Type; + try + { + if (type == null || type.PoiTypeId != poi.PoiTypeId) + type = _poiTypes != null ? await _poiTypes.GetPoiTypeByTypeIdAsync(poi.PoiTypeId) : null; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Search projection could not resolve the type of POI {poi.PoiId}."); + return; + } + await Guarded(SearchEntityTypes.Poi, type?.DepartmentId ?? 0, poi.PoiId.ToString(), false, () => BuildPoiAsync(poi, type), cancellationToken); + } + + public Task ProjectShiftAsync(Shift shift, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.Shift, shift?.DepartmentId ?? 0, shift?.ShiftId.ToString(), false, () => BuildShiftAsync(shift), cancellationToken); + + public Task ProjectGroupAsync(DepartmentGroup group, CancellationToken cancellationToken = default) + { + if (group != null) + _groupCache.TryRemove(group.DepartmentGroupId, out _); + return Guarded(SearchEntityTypes.Group, group?.DepartmentId ?? 0, group?.DepartmentGroupId.ToString(), false, () => BuildGroupAsync(group), cancellationToken); + } + + public Task ProjectOccupancyAsync(RmsOccupancy occupancy, CancellationToken cancellationToken = default) + => Guarded(SearchEntityTypes.Occupancy, occupancy?.DepartmentId ?? 0, occupancy?.RmsOccupancyId, + occupancy != null && (occupancy.DeletedOn.HasValue || occupancy.Status == (int)RmsOccupancyStatus.Merged), () => BuildOccupancyAsync(occupancy), cancellationToken); + + public async Task RefreshAsync(int departmentId, string entityType, string entityId, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(entityId)) + return; + try + { + switch (entityType) + { + case SearchEntityTypes.Call: + if (_calls == null || !int.TryParse(entityId, out var callId)) return; + var call = await _calls.GetByIdAsync(callId); + if (call != null && call.DepartmentId == departmentId) + await ProjectCallAsync(call, cancellationToken); + return; + case SearchEntityTypes.Unit: + if (_units == null || !int.TryParse(entityId, out var unitId)) return; + var unit = await _units.GetByIdAsync(unitId); + if (unit != null && unit.DepartmentId == departmentId) + await ProjectUnitAsync(unit, cancellationToken); + return; + case SearchEntityTypes.Contact: + if (_contacts == null) return; + var contact = await _contacts.GetByIdAsync(entityId); + if (contact != null && contact.DepartmentId == departmentId) + await ProjectContactAsync(contact, cancellationToken); + return; + case SearchEntityTypes.Personnel: + await RefreshPersonnelAsync(departmentId, entityId, cancellationToken); + return; + case SearchEntityTypes.Occupancy: + if (_occupancies == null) return; + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, entityId); + if (occupancy == null) + await RemoveAsync(departmentId, SearchEntityTypes.Occupancy, entityId, cancellationToken); + else if (occupancy.DepartmentId == departmentId) + await ProjectOccupancyAsync(occupancy, cancellationToken); + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Never fail the caller's write over the search projection; the next rebuild reconciles. + Logging.LogException(ex, $"Search projection refresh failed for {entityType} {entityId} in department {departmentId}."); + } + } + + /// Same membership rule as the rebuild: deleted, disabled and hidden members are off the personnel list and out of the index. + private async Task RefreshPersonnelAsync(int departmentId, string userId, CancellationToken cancellationToken) + { + if (_profiles == null || _members == null) + return; + var member = await _members.GetDepartmentMemberByDepartmentIdAndUserIdAsync(departmentId, userId); + if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault() || member.IsHidden.GetValueOrDefault()) + { + await RemoveAsync(departmentId, SearchEntityTypes.Personnel, userId, cancellationToken); + return; + } + var profile = await _profiles.GetProfileByUserIdAsync(userId); + if (profile == null) + return; + int? groupId = null; + if (_groupMembers != null) + groupId = (await _groupMembers.GetAllGroupMembersByUserAndDepartmentAsync(userId, departmentId) ?? Enumerable.Empty()) + .FirstOrDefault(m => m != null && m.DepartmentId == departmentId)?.DepartmentGroupId; + await Guarded(SearchEntityTypes.Personnel, departmentId, userId, false, () => BuildPersonnelAsync(departmentId, profile, groupId, member.IsActive), cancellationToken); + } + + public async Task RefreshGroupDependentsAsync(int departmentId, int departmentGroupId, CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || departmentGroupId <= 0) + return; + _groupCache.TryRemove(departmentGroupId, out _); + try + { + if (_groupMembers != null) + foreach (var member in await _groupMembers.GetAllGroupMembersByGroupIdAsync(departmentGroupId) ?? Enumerable.Empty()) + if (member != null && member.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(member.UserId)) + await RefreshPersonnelAsync(departmentId, member.UserId, cancellationToken); + if (_units != null) + foreach (var unit in await _units.GetAllUnitsByGroupIdAsync(departmentGroupId) ?? Enumerable.Empty()) + if (unit != null && unit.DepartmentId == departmentId) + await ProjectUnitAsync(unit, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Search projection refresh failed for the members and units of group {departmentGroupId} in department {departmentId}."); + } + } + public async Task RemoveAsync(int departmentId, string entityType, string entityId, CancellationToken cancellationToken = default) { if (departmentId <= 0 || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(entityId)) @@ -154,13 +357,23 @@ public async Task BuildCallAsync(Call call) var incident = ctx.ProtectedTextAllowed ? Safe(call.IncidentNumber) : null; var reference = ctx.ProtectedTextAllowed ? Safe(call.ReferenceNumber) : null; var external = ctx.ProtectedTextAllowed ? Safe(call.ExternalIdentifier) : null; + // Calls.Notes, Calls.CompletedNotes and CallNotes.Note are PHI in the catalog: projected under the same rule as the + // nature and address. They are what lets a department find every call whose notes mention a given alarm point. + var notes = ctx.ProtectedTextAllowed ? Strip(Safe(call.Notes)) : null; + var completedNotes = ctx.ProtectedTextAllowed ? Strip(Safe(call.CompletedNotes)) : null; + var callNotes = ctx.ProtectedTextAllowed ? await CallNotesTextAsync(call) : null; + // The caller (Calls.ContactName / ContactNumber, PII) answers "every call this alarm company placed"; the + // department's custom call fields (UdfFieldValues.Value) carry account numbers and the like. Same rule. + var callerName = ctx.ProtectedTextAllowed ? Safe(call.ContactName) : null; + var callerNumber = ctx.ProtectedTextAllowed ? Safe(call.ContactNumber) : null; + var custom = ctx.ProtectedTextAllowed ? await CustomFieldsTextAsync(call.DepartmentId, UdfEntityType.Call, call.CallId.ToString()) : null; var state = Enum.IsDefined(typeof(CallStates), call.State) ? ((CallStates)call.State).ToString() : call.State.ToString(); var p = New(call.DepartmentId, SearchEntityTypes.Call, call.CallId.ToString(), ctx); p.Title = Cap(name ?? (number != null ? "Call " + number : "Call " + call.CallId), TitleMax); p.Summary = Cap(Join(" · ", nature, type), SummaryMax); - p.Keywords = Cap(Join(" ", number, incident, reference, external), KeywordsMax); - p.SearchText = Cap(Join(" ", address, nature, type), SearchTextMax); + p.Keywords = Cap(Join(" ", number, incident, reference, external, ctx.ProtectedTextAllowed ? Digits(call.ContactNumber) : null), KeywordsMax); + p.SearchText = Cap(Join(" ", address, nature, type, callerName, callerNumber, notes, completedNotes, callNotes, custom), CallSearchTextMax); p.Category = type; p.Status = state; p.Priority = call.Priority; @@ -179,6 +392,24 @@ public async Task BuildCallAsync(Call call) return p; } + /// The text of the call's live (not deleted) notes, oldest first. Read from the repository when available: a call + /// handed to a save path can carry only the notes that request posted. + private async Task CallNotesTextAsync(Call call) + { + IEnumerable rows = null; + if (_callNotes != null && call.CallId > 0) + rows = await _callNotes.GetCallNotesByCallIdAsync(call.CallId); + rows ??= call.CallNotes; + + var texts = (rows ?? Enumerable.Empty()) + .Where(n => n != null && !n.IsDeleted) + .OrderBy(n => n.Timestamp) + .Select(n => Strip(Safe(n.Note))) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToList(); + return texts.Count == 0 ? null : string.Join(" ", texts); + } + public async Task BuildUnitAsync(Unit unit) { if (unit == null || unit.DepartmentId <= 0 || unit.UnitId <= 0) @@ -189,16 +420,21 @@ public async Task BuildUnitAsync(Unit unit) if (name == null) return null; + // The station name lets "Station 4" find the station's apparatus; custom unit fields are cataloged values. + var station = Safe((unit.StationGroup != null && unit.StationGroup.DepartmentGroupId == unit.StationGroupId ? unit.StationGroup : await GroupAsync(unit.StationGroupId))?.Name); + var custom = ctx.ProtectedTextAllowed ? await CustomFieldsTextAsync(unit.DepartmentId, UdfEntityType.Unit, unit.UnitId.ToString()) : null; + var p = New(unit.DepartmentId, SearchEntityTypes.Unit, unit.UnitId.ToString(), ctx); p.Title = Cap(name, TitleMax); - p.Summary = Cap(Safe(unit.Type), SummaryMax); + p.Summary = Cap(Join(" · ", Safe(unit.Type), station), SummaryMax); p.Keywords = Cap(Join(" ", name, Safe(unit.VIN), Safe(unit.PlateNumber)), KeywordsMax); + p.SearchText = Cap(Join(" ", station, custom), SearchTextMax); p.Category = Safe(unit.Type); p.GroupId = unit.StationGroupId; p.IsActive = true; p.OccurredOn = DateTime.UtcNow; p.Url = "/User/Units"; - p.MetadataJson = Json(new Dictionary { ["Type"] = Safe(unit.Type), ["StationGroupId"] = unit.StationGroupId?.ToString() }); + p.MetadataJson = Json(new Dictionary { ["Type"] = Safe(unit.Type), ["StationGroupId"] = unit.StationGroupId?.ToString(), ["Station"] = station }); return p; } @@ -214,19 +450,26 @@ public async Task BuildPersonnelAsync(int departmentId, UserPr if (name == null) return null; - // Member identification numbers are cataloged (DepartmentMemberSensitiveData); the legacy profile column - // is treated the same way. Phones, e-mail and addresses are never projected (plan R3). - var idNumber = ctx.ProtectedTextAllowed ? Safe(profile.IdentificationNumber) : null; + // Member identification numbers are cataloged and live per department on DepartmentMemberSensitiveData; the + // legacy profile column is not mapped any more and only a host without that repository falls back to it. + // Phones, e-mail and addresses are never projected (plan R3). + var idNumber = ctx.ProtectedTextAllowed ? Safe(await IdentificationNumberAsync(departmentId, profile)) : null; + // Group and role names are plain configuration: "engineer", "captain" or "Station 2" finds the people. + var group = Safe((await GroupAsync(groupId))?.Name); + var roles = await RoleNamesAsync(departmentId, profile.UserId); + var custom = ctx.ProtectedTextAllowed ? await CustomFieldsTextAsync(departmentId, UdfEntityType.Personnel, profile.UserId) : null; var p = New(departmentId, SearchEntityTypes.Personnel, profile.UserId, ctx); p.Title = Cap(name, TitleMax); + p.Summary = Cap(Join(" · ", group, roles), SummaryMax); p.Keywords = Cap(Join(" ", idNumber, first, last), KeywordsMax); + p.SearchText = Cap(Join(" ", group, roles, custom), SearchTextMax); p.GroupId = groupId; p.OwnerUserId = profile.UserId; p.IsActive = isActive; p.OccurredOn = profile.LastUpdated ?? DateTime.UtcNow; p.Url = $"/User/Personnel/ViewPerson?userId={Uri.EscapeDataString(profile.UserId)}"; - p.MetadataJson = Json(new Dictionary { ["IdentificationNumber"] = idNumber, ["GroupId"] = groupId?.ToString(), ["IsActive"] = isActive ? "true" : "false" }); + p.MetadataJson = Json(new Dictionary { ["IdentificationNumber"] = idNumber, ["GroupId"] = groupId?.ToString(), ["Group"] = group, ["IsActive"] = isActive ? "true" : "false" }); return p; } @@ -241,6 +484,7 @@ public async Task BuildContactAsync(Contact contact) return null; var first = Safe(contact.FirstName); + var middle = Safe(contact.MiddleName); var last = Safe(contact.LastName); var company = Safe(contact.CompanyName); var other = Safe(contact.OtherName); @@ -248,16 +492,24 @@ public async Task BuildContactAsync(Contact contact) if (title == null) return null; + // Addresses (Addresses rows behind PhysicalAddressId / MailingAddressId), the contact's live notes + // (ContactNotes.Note, cataloged) and its category name, so "123 Main" or a note about the alarm panel finds it. + var physical = await AddressTextAsync(contact.PhysicalAddressId); + var mailing = await AddressTextAsync(contact.MailingAddressId); + var notes = await ContactNotesTextAsync(contact); + var category = await CategoryNameAsync(contact.DepartmentId, contact.ContactCategoryId); + var custom = await CustomFieldsTextAsync(contact.DepartmentId, UdfEntityType.Contact, contact.ContactId); + var p = New(contact.DepartmentId, SearchEntityTypes.Contact, contact.ContactId, ctx); p.Title = Cap(title, TitleMax); - p.Summary = Cap(Join(" · ", company != null && title != company ? company : null, Safe(contact.Description)), SummaryMax); - p.Keywords = Cap(Join(" ", Safe(contact.Email), Digits(contact.CellPhoneNumber), Digits(contact.HomePhoneNumber), Digits(contact.OfficePhoneNumber)), KeywordsMax); - p.SearchText = Cap(Join(" ", other, company, Safe(contact.Email), Safe(contact.OtherInfo), Safe(contact.Website)), SearchTextMax); + p.Summary = Cap(Join(" · ", company != null && title != company ? company : null, physical, Safe(contact.Description)), SummaryMax); + p.Keywords = Cap(Join(" ", Safe(contact.Email), Digits(contact.CellPhoneNumber), Digits(contact.HomePhoneNumber), Digits(contact.OfficePhoneNumber), Digits(contact.FaxPhoneNumber)), KeywordsMax); + p.SearchText = Cap(Join(" ", middle, other, company, category, physical, mailing, Safe(contact.Email), Safe(contact.OtherInfo), Safe(contact.Website), notes, custom), SearchTextMax); p.Category = contact.ContactType == 1 ? "Company" : "Person"; p.IsActive = true; p.OccurredOn = DateTime.UtcNow; p.Url = $"/User/Contacts/View?contactId={Uri.EscapeDataString(contact.ContactId)}"; - p.MetadataJson = Json(new Dictionary { ["ContactType"] = contact.ContactType.ToString(), ["CategoryId"] = Safe(contact.ContactCategoryId) }); + p.MetadataJson = Json(new Dictionary { ["ContactType"] = contact.ContactType.ToString(), ["CategoryId"] = Safe(contact.ContactCategoryId), ["Category"] = category }); return p; } @@ -429,7 +681,9 @@ public async Task BuildDeploymentAsync(Deployment deployment) var p = New(deployment.DepartmentId, SearchEntityTypes.Deployment, deployment.DeploymentId, ctx); p.Title = Cap(name, TitleMax); p.Summary = Cap(Join(" · ", status, Safe(deployment.IncidentNumber), Safe(deployment.ResourceOrderNumber), deployment.StartOn?.ToString("yyyy-MM-dd")), SummaryMax); - p.Keywords = Cap(Join(" ", Safe(deployment.IncidentNumber), Safe(deployment.ResourceOrderNumber), Safe(deployment.RequestNumber), Safe(deployment.CostCode), deployment.CallId?.ToString()), KeywordsMax); + p.Keywords = Cap(Join(" ", Safe(deployment.IncidentNumber), Safe(deployment.ResourceOrderNumber), Safe(deployment.RequestNumber), Safe(deployment.ServiceRequestNumber), + Safe(deployment.RmsExternalOrderId), Safe(deployment.CostCode), deployment.CallId?.ToString()), KeywordsMax); + p.SearchText = Cap(Safe(deployment.PointOfHire), SearchTextMax); p.Category = status; p.IsActive = deployment.IsOpen; p.OccurredOn = deployment.StartOn ?? (deployment.AddedOn == default ? DateTime.UtcNow : deployment.AddedOn); @@ -470,6 +724,328 @@ public async Task BuildCertificationTypeAsync(DepartmentCertif return p; } + // ---- operations reference families (plan R3 Tier 2) -------------------------------------------------------- + + public async Task BuildProtocolAsync(DispatchProtocol protocol) + { + if (protocol == null || protocol.DepartmentId <= 0 || protocol.DispatchProtocolId <= 0) + return null; + var name = Safe(protocol.Name); + if (name == null) + return null; + + var ctx = await ContextAsync(protocol.DepartmentId); + var p = New(protocol.DepartmentId, SearchEntityTypes.Protocol, protocol.DispatchProtocolId.ToString(), ctx); + p.Title = Cap(name, TitleMax); + p.Summary = Cap(Strip(Safe(protocol.Description)), SummaryMax); + p.Keywords = Cap(Safe(protocol.Code), KeywordsMax); + p.SearchText = Cap(Join(" ", Strip(Safe(protocol.Description)), Strip(Safe(protocol.ProtocolText))), SearchTextMax); + p.Category = Safe(protocol.Code); + p.Status = protocol.IsDisabled ? "Disabled" : "Active"; + p.IsActive = !protocol.IsDisabled; + p.OccurredOn = protocol.UpdatedOn ?? (protocol.CreatedOn == default ? DateTime.UtcNow : protocol.CreatedOn); + p.Url = $"/User/Protocols/View?id={protocol.DispatchProtocolId}"; + p.MetadataJson = Json(new Dictionary { ["Code"] = Safe(protocol.Code) }); + return p; + } + + public async Task BuildTrainingAsync(Training training) + { + if (training == null || training.DepartmentId <= 0 || training.TrainingId <= 0) + return null; + var name = Safe(training.Name); + if (name == null) + return null; + + var ctx = await ContextAsync(training.DepartmentId); + var p = New(training.DepartmentId, SearchEntityTypes.Training, training.TrainingId.ToString(), ctx); + p.Title = Cap(name, TitleMax); + p.Summary = Cap(Strip(Safe(training.Description)), SummaryMax); + p.SearchText = Cap(Join(" ", Strip(Safe(training.Description)), Strip(Safe(training.TrainingText))), SearchTextMax); + p.Status = training.ToBeCompletedBy.HasValue ? "Due " + training.ToBeCompletedBy.Value.ToString("yyyy-MM-dd") : null; + p.IsActive = !training.ToBeCompletedBy.HasValue || training.ToBeCompletedBy.Value >= DateTime.UtcNow.Date; + p.OccurredOn = training.CreatedOn == default ? DateTime.UtcNow : training.CreatedOn; + p.Url = $"/User/Trainings/View?trainingId={training.TrainingId}"; + p.MetadataJson = Json(new Dictionary { ["ToBeCompletedBy"] = training.ToBeCompletedBy?.ToString("o") }); + return p; + } + + public async Task BuildCalendarItemAsync(CalendarItem item) + { + if (item == null || item.DepartmentId <= 0 || item.CalendarItemId <= 0) + return null; + // One document per event: the series parent stands for its occurrences, which only repeat its text. + if (!string.IsNullOrWhiteSpace(item.RecurrenceId)) + return null; + + // CalendarItems.Title / Description / Location are cataloged (catalog 9): projected under the call-text rule. + var ctx = await ContextAsync(item.DepartmentId); + if (!ctx.ProtectedTextAllowed) + return null; + var title = Safe(item.Title); + if (title == null) + return null; + var location = Safe(item.Location); + + var p = New(item.DepartmentId, SearchEntityTypes.CalendarEvent, item.CalendarItemId.ToString(), ctx); + p.Title = Cap(title, TitleMax); + p.Summary = Cap(Join(" · ", location, item.IsAllDay ? item.Start.ToString("yyyy-MM-dd") : null), SummaryMax); + p.SearchText = Cap(Join(" ", location, Strip(Safe(item.Description))), SearchTextMax); + p.Category = item.RecurrenceType > 0 ? "Recurring" : null; + p.IsActive = item.End >= DateTime.UtcNow || item.RecurrenceType > 0 && (!item.RecurrenceEnd.HasValue || item.RecurrenceEnd.Value >= DateTime.UtcNow); + p.OccurredOn = item.Start == default ? DateTime.UtcNow : item.Start; + p.OwnerUserId = Safe(item.CreatorUserId); + p.Url = $"/User/Calendar/View?calendarItemId={item.CalendarItemId}"; + p.MetadataJson = Json(new Dictionary { ["Start"] = item.Start.ToString("o"), ["End"] = item.End.ToString("o"), ["AllDay"] = item.IsAllDay ? "true" : null }); + return p; + } + + public async Task BuildLogAsync(Log log) + { + if (log == null || log.DepartmentId <= 0 || log.LogId <= 0) + return null; + + // Logs.Narrative, InitialReport, Cause, ContactName/Number, OtherPersonnel, Location, BodyLocation and + // PronouncedDeceasedBy are cataloged (catalog 3) and follow the call-text rule; the course, instructors, other + // agencies/units and identifiers are plain. + var ctx = await ContextAsync(log.DepartmentId); + var allowed = ctx.ProtectedTextAllowed; + var kind = log.LogType.HasValue && Enum.IsDefined(typeof(LogTypes), log.LogType.Value) ? ((LogTypes)log.LogType.Value).ToString() : null; + var type = Safe(log.Type); + var course = Safe(log.Course); + var narrative = allowed ? Strip(Safe(log.Narrative)) : null; + var initial = allowed ? Strip(Safe(log.InitialReport)) : null; + var location = allowed ? Safe(log.Location) : null; + + var p = New(log.DepartmentId, SearchEntityTypes.Log, log.LogId.ToString(), ctx); + p.Title = Cap(Join(" · ", kind ?? "Log", course ?? type) ?? "Log " + log.LogId, TitleMax); + p.Summary = Cap(narrative == null ? location : narrative.Substring(0, Math.Min(narrative.Length, 200)), SummaryMax); + p.Keywords = Cap(Join(" ", Safe(log.ExternalId), Safe(log.CourseCode)), KeywordsMax); + p.SearchText = Cap(Join(" ", type, course, Safe(log.CourseCode), Safe(log.Instructors), Safe(log.OtherAgencies), Safe(log.OtherUnits), + narrative, initial, location, allowed ? Strip(Safe(log.Cause)) : null, allowed ? Safe(log.ContactName) : null, + allowed ? Safe(log.ContactNumber) : null, allowed ? Safe(log.OtherPersonnel) : null, allowed ? Safe(log.BodyLocation) : null, + allowed ? Safe(log.PronouncedDeceasedBy) : null), CallSearchTextMax); + p.Category = kind; + p.GroupId = log.StationGroupId; + p.IsActive = true; + p.OccurredOn = log.StartedOn ?? (log.LoggedOn == default ? DateTime.UtcNow : log.LoggedOn); + p.OwnerUserId = Safe(log.LoggedByUserId); + p.Url = $"/User/Logs/View?logId={log.LogId}"; + p.MetadataJson = Json(new Dictionary { ["LogType"] = kind, ["CallId"] = log.CallId?.ToString() }); + return p; + } + + public async Task BuildPoiAsync(Poi poi, PoiType type) + { + if (poi == null || poi.PoiId <= 0 || type == null || type.DepartmentId <= 0 || type.PoiTypeId != poi.PoiTypeId) + return null; + var typeName = Safe(type.Name); + var name = Safe(poi.Name); + var address = Safe(poi.Address); + if (name == null && address == null) + return null; + + var ctx = await ContextAsync(type.DepartmentId); + var p = New(type.DepartmentId, SearchEntityTypes.Poi, poi.PoiId.ToString(), ctx); + p.Title = Cap(name ?? address, TitleMax); + p.Summary = Cap(Join(" · ", typeName, name != null ? address : null), SummaryMax); + p.SearchText = Cap(Join(" ", typeName, address, Strip(Safe(poi.Note))), SearchTextMax); + p.Category = typeName; + p.IsActive = true; + p.OccurredOn = DateTime.UtcNow; + p.Url = $"/User/Mapping/EditPOI?poiId={poi.PoiId}"; + p.MetadataJson = Json(new Dictionary { ["PoiTypeId"] = type.PoiTypeId.ToString(), ["Type"] = typeName }); + return p; + } + + public async Task BuildShiftAsync(Shift shift) + { + if (shift == null || shift.DepartmentId <= 0 || shift.ShiftId <= 0) + return null; + var name = Safe(shift.Name); + if (name == null) + return null; + + var ctx = await ContextAsync(shift.DepartmentId); + var hours = Safe(shift.StartTime) != null || Safe(shift.EndTime) != null ? $"{Safe(shift.StartTime)}–{Safe(shift.EndTime)}" : null; + var p = New(shift.DepartmentId, SearchEntityTypes.Shift, shift.ShiftId.ToString(), ctx); + p.Title = Cap(name, TitleMax); + p.Summary = Cap(Join(" · ", Safe(shift.Code), hours), SummaryMax); + p.Keywords = Cap(Safe(shift.Code), KeywordsMax); + p.IsActive = true; + p.OccurredOn = shift.StartDay == default ? DateTime.UtcNow : shift.StartDay; + p.Url = $"/User/Shifts/ShiftCalendar?shiftId={shift.ShiftId}"; + p.MetadataJson = Json(new Dictionary { ["Code"] = Safe(shift.Code) }); + return p; + } + + public async Task BuildGroupAsync(DepartmentGroup group) + { + if (group == null || group.DepartmentId <= 0 || group.DepartmentGroupId <= 0) + return null; + var name = Safe(group.Name); + if (name == null) + return null; + + var ctx = await ContextAsync(group.DepartmentId); + var isStation = group.Type == (int)DepartmentGroupTypes.Station; + var address = group.Address != null ? AddressText(group.Address) : await AddressTextAsync(group.AddressId); + var p = New(group.DepartmentId, SearchEntityTypes.Group, group.DepartmentGroupId.ToString(), ctx); + p.Title = Cap(name, TitleMax); + p.Summary = Cap(Join(" · ", isStation ? "Station" : "Group", address), SummaryMax); + p.SearchText = Cap(address, SearchTextMax); + p.Category = isStation ? "Station" : "Group"; + p.GroupId = group.DepartmentGroupId; + p.IsActive = true; + p.OccurredOn = DateTime.UtcNow; + p.Url = "/User/Groups"; + p.MetadataJson = Json(new Dictionary { ["Type"] = isStation ? "Station" : "Group", ["ParentGroupId"] = group.ParentDepartmentGroupId?.ToString() }); + return p; + } + + public async Task BuildOccupancyAsync(RmsOccupancy occupancy) + { + if (occupancy == null || occupancy.DepartmentId <= 0 || string.IsNullOrWhiteSpace(occupancy.RmsOccupancyId) || occupancy.DeletedOn.HasValue + || occupancy.Status == (int)RmsOccupancyStatus.Merged) + return null; + var name = Safe(occupancy.Name); + if (name == null) + return null; + + // Name, number, address and parcel are plain. The alarm company, alarm panel location, hazard notes and tactical + // summary are cataloged (RMS prevention) and follow the protected-text rule. Gate codes, Knox box locations, + // emergency contacts, access notes and occupants needing assistance never enter the shared index. + var ctx = await ContextAsync(occupancy.DepartmentId); + var allowed = ctx.ProtectedTextAllowed; + var address = Join(" ", Safe(occupancy.AddressText), Safe(occupancy.City), Safe(occupancy.StateProvince), Safe(occupancy.PostalCode)); + string hazards = null; + if (_occupancyHazards != null) + { + var titles = (await _occupancyHazards.GetForOccupancyAsync(occupancy.DepartmentId, occupancy.RmsOccupancyId) ?? Enumerable.Empty()) + .Where(h => h != null && !h.DeletedOn.HasValue).Select(h => Safe(h.Title)).Where(t => t != null).ToList(); + hazards = titles.Count == 0 ? null : string.Join(" ", titles); + } + var status = Enum.IsDefined(typeof(RmsOccupancyStatus), occupancy.Status) ? ((RmsOccupancyStatus)occupancy.Status).ToString() : null; + + var p = New(occupancy.DepartmentId, SearchEntityTypes.Occupancy, occupancy.RmsOccupancyId, ctx); + p.Title = Cap(name, TitleMax); + p.Summary = Cap(Join(" · ", address, allowed ? Safe(occupancy.AlarmCompany) : null), SummaryMax); + p.Keywords = Cap(Join(" ", Safe(occupancy.OccupancyNumber), Safe(occupancy.ParcelId), allowed ? Digits(occupancy.AlarmCompanyPhone) : null), KeywordsMax); + p.SearchText = Cap(Join(" ", address, hazards, allowed ? Safe(occupancy.AlarmCompany) : null, allowed ? Safe(occupancy.AlarmPanelLocation) : null, + allowed ? Strip(Safe(occupancy.GeneralHazardNotes)) : null, allowed ? Strip(Safe(occupancy.TacticalSummary)) : null), SearchTextMax); + p.Category = status; + p.Status = status; + p.IsActive = occupancy.Status == (int)RmsOccupancyStatus.Active; + p.OccurredOn = occupancy.ModifiedOn == default ? (occupancy.CreatedOn == default ? DateTime.UtcNow : occupancy.CreatedOn) : occupancy.ModifiedOn; + p.Url = $"/User/RecordOccupancies/Details?id={Uri.EscapeDataString(occupancy.RmsOccupancyId)}"; + p.MetadataJson = Json(new Dictionary { ["Number"] = Safe(occupancy.OccupancyNumber), ["Status"] = status }); + return p; + } + + // ---- enrichment ------------------------------------------------------------------------------------------ + + private async Task GroupAsync(int? departmentGroupId) + { + if (!departmentGroupId.HasValue || departmentGroupId.Value <= 0 || _groups == null) + return null; + if (_groupCache.TryGetValue(departmentGroupId.Value, out var cached)) + return cached; + var group = await _groups.GetGroupByGroupIdAsync(departmentGroupId.Value); + _groupCache[departmentGroupId.Value] = group; + return group; + } + + private async Task RoleNamesAsync(int departmentId, string userId) + { + if (_personnelRoles == null || string.IsNullOrWhiteSpace(userId)) + return null; + var names = (await _personnelRoles.GetRolesForUserAsync(departmentId, userId) ?? Enumerable.Empty()) + .Where(r => r != null && r.DepartmentId == departmentId) + .Select(r => Safe(r.Name)).Where(n => n != null).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(n => n).ToList(); + return names.Count == 0 ? null : string.Join(", ", names); + } + + private async Task IdentificationNumberAsync(int departmentId, UserProfile profile) + { + if (_memberSensitiveData == null) + return profile.IdentificationNumber; + // A member with no row for this department has no number here, whatever the legacy global column says. + return (await _memberSensitiveData.GetByDepartmentAndUserAsync(departmentId, profile.UserId))?.IdentificationNumber; + } + + private async Task AddressTextAsync(int? addressId) + { + if (!addressId.HasValue || addressId.Value <= 0 || _addresses == null) + return null; + return AddressText(await _addresses.GetByIdAsync(addressId.Value)); + } + + private static string AddressText(Address address) => + address == null ? null : Join(" ", Safe(address.Address1), Safe(address.City), Safe(address.State), Safe(address.PostalCode)); + + private async Task ContactNotesTextAsync(Contact contact) + { + if (_contactNotes == null) + return null; + // Filter here: the service read of a contact's notes has returned deleted ones as well. + var texts = (await _contactNotes.GetContactNotesByContactIdAsync(contact.ContactId) ?? Enumerable.Empty()) + .Where(n => n != null && !n.IsDeleted && n.DepartmentId == contact.DepartmentId) + .OrderBy(n => n.AddedOn) + .Select(n => Strip(Safe(n.Note))) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToList(); + return texts.Count == 0 ? null : string.Join(" ", texts); + } + + private async Task CategoryNameAsync(int departmentId, string categoryId) + { + if (string.IsNullOrWhiteSpace(categoryId) || _contactCategories == null) + return null; + if (_categoryCache.TryGetValue(categoryId, out var cached)) + return cached; + var category = await _contactCategories.GetByIdAsync(categoryId); + var name = category != null && category.DepartmentId == departmentId ? Safe(category.Name) : null; + _categoryCache[categoryId] = name; + return name; + } + + /// + /// "Label value" for each custom field value of the entity whose field anyone who can open the entity may see: + /// visibility Everyone, sensitivity None, enabled. Admin-only and restricted fields never enter the shared index. + /// UdfFieldValues.Value is cataloged, so callers apply the protected-text rule. + /// + private async Task CustomFieldsTextAsync(int departmentId, UdfEntityType entityType, string entityId) + { + if (_udfDefinitions == null || _udfFields == null || _udfValues == null || string.IsNullOrWhiteSpace(entityId)) + return null; + + var key = (departmentId, (int)entityType); + if (!_udfDefinitionCache.TryGetValue(key, out var definitionId)) + { + var definition = await _udfDefinitions.GetActiveDefinitionByDepartmentAndEntityTypeAsync(departmentId, (int)entityType); + definitionId = definition?.UdfDefinitionId; + _udfDefinitionCache[key] = definitionId; + _udfFieldCache[key] = definitionId == null ? new List() + : (await _udfFields.GetFieldsByDefinitionIdAsync(definitionId) ?? Enumerable.Empty()) + .Where(f => f != null && f.IsEnabled && f.Visibility == (int)UdfFieldVisibility.Everyone && f.Sensitivity == (int)UdfFieldSensitivity.None) + .ToList(); + } + if (definitionId == null || !_udfFieldCache.TryGetValue(key, out var fields) || fields.Count == 0) + return null; + + var byId = fields.ToDictionary(f => f.UdfFieldId, StringComparer.Ordinal); + var parts = new List(); + foreach (var value in (await _udfValues.GetFieldValuesByEntityAsync((int)entityType, entityId, definitionId) ?? Enumerable.Empty()) + .Where(v => v != null && v.UdfFieldId != null && byId.ContainsKey(v.UdfFieldId)).OrderBy(v => byId[v.UdfFieldId].SortOrder)) + { + var text = Safe(value.Value); + if (text == null) + continue; + parts.Add(Join(" ", Safe(byId[value.UdfFieldId].Label) ?? Safe(byId[value.UdfFieldId].Name), text)); + } + return parts.Count == 0 ? null : string.Join(" ", parts); + } + // ---- helpers --------------------------------------------------------------------------------------------- private sealed class ProjectionContext diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs index 0dadd9c00..f2c3ac478 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs @@ -43,6 +43,8 @@ private sealed class SearchAccess public Dictionary Deployments; /// Deployments the caller is rostered on; loaded only when the caller lacks the Deployments/View claim. public HashSet RosteredDeploymentIds; + /// Records occupancy module open for this caller (module flag, Records usable, active Records member); null until checked. + public bool? OccupancyOpen; public string GlobalGeneration => GlobalSearchGeneration.Compute(CatalogVersion, PolicyEpoch); public string RecordsGeneration => RecordsSearchGeneration.Compute(CatalogVersion, PolicyEpoch); } @@ -178,6 +180,42 @@ private async Task AuthorizeAsync(GlobalSearchHit hit, SearchAccess access if (deployment.DepartmentId != departmentId || deployment.IsDeleted) return false; // Rostered members reach their own deployments without the claim, exactly as the deployment page does. return principal.IsDepartmentAdmin || principal.HasResourceClaim("Deployments", "View") || access.RosteredDeploymentIds?.Contains(deployment.DeploymentId) == true; + // Operations reference families: the row must still exist in the caller's department; the family's claim and + // module switch were applied when the family was admitted (AllowedTypes), as the pages apply them. + case SearchEntityTypes.Protocol: + if (!int.TryParse(hit.EntityId, out var protocolId)) return false; + return await _authorization.CanUserViewProtocolAsync(userId, protocolId); + case SearchEntityTypes.Training: + if (_trainings?.Value == null || !int.TryParse(hit.EntityId, out var trainingId)) return false; + var training = await _trainings.Value.GetTrainingByIdAsync(trainingId); + return training != null && training.DepartmentId == departmentId; + case SearchEntityTypes.CalendarEvent: + if (_calendar?.Value == null || !int.TryParse(hit.EntityId, out var calendarItemId)) return false; + var calendarItem = await _calendar.Value.GetCalendarItemByIdAsync(calendarItemId); + return calendarItem != null && calendarItem.DepartmentId == departmentId && access.ProtectedTextAllowed; + case SearchEntityTypes.Log: + if (_logs?.Value == null || !int.TryParse(hit.EntityId, out var logId)) return false; + var log = await _logs.Value.GetWorkLogByIdAsync(logId); + return log != null && log.DepartmentId == departmentId; + case SearchEntityTypes.Poi: + if (_mapping?.Value == null || !int.TryParse(hit.EntityId, out var poiId)) return false; + var poi = await _mapping.Value.GetPOIByIdAsync(poiId); + if (poi == null) return false; + var poiType = await _mapping.Value.GetTypeByIdAsync(poi.PoiTypeId); + return poiType != null && poiType.DepartmentId == departmentId; + case SearchEntityTypes.Shift: + if (_shifts?.Value == null || !int.TryParse(hit.EntityId, out var shiftId)) return false; + var shift = await _shifts.Value.GetShiftByIdAsync(shiftId); + return shift != null && shift.DepartmentId == departmentId; + case SearchEntityTypes.Occupancy: + if (_occupancies == null || !await OccupancyModuleOpenAsync(access)) return false; + var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, hit.EntityId); + return occupancy != null && occupancy.DepartmentId == departmentId && !occupancy.DeletedOn.HasValue && + occupancy.Status != (int)RmsOccupancyStatus.Merged; + case SearchEntityTypes.Group: + if (!int.TryParse(hit.EntityId, out var groupId)) return false; + var departmentGroup = await _groups.GetGroupByIdAsync(groupId); + return departmentGroup != null && departmentGroup.DepartmentId == departmentId; case SearchEntityTypes.CertificationType: if (_certifications?.Value == null || !int.TryParse(hit.EntityId, out var typeId) || !principal.HasResourceClaim("Certifications", "View") && !principal.IsDepartmentAdmin) return false; var certificationType = await _certifications.Value.GetCertificationTypeByIdAsync(typeId); @@ -229,6 +267,27 @@ private async Task PreloadDeploymentsAsync(IEnumerable hits, Se } } + /// The occupancy pages' gate: the module flag on a usable Records module, and an active member with Records access. + private async Task OccupancyModuleOpenAsync(SearchAccess access) + { + if (access.OccupancyOpen.HasValue) + return access.OccupancyOpen.Value; + var open = false; + try + { + var gate = _preventionGate?.Value; + open = gate != null && _occupancies != null && + await gate.IsEnabledAsync(access.Principal.DepartmentId, RecordsPreventionModule.Occupancy) && + await _recordsAuthorization.IsActiveMemberAsync(access.Principal.UserId, access.Principal.DepartmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, "Records occupancy access could not be verified for search."); + } + access.OccupancyOpen = open; + return open; + } + private bool CanViewGroup(Permission permission, int? targetGroupId, SearchAccess access) { if (permission == null) return true; diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs index 54463c2e2..0bc8f86a7 100644 --- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs +++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs @@ -1,10 +1,12 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Invoicing; @@ -25,12 +27,29 @@ public partial class UnifiedSearchService : IUnifiedSearchService { private const int CandidateWindow = 200; + /// How long this process trusts that a department's state row exists and is Ready before reading it again. + private static readonly TimeSpan ReadyMemo = TimeSpan.FromMinutes(5); + + /// + /// Departments seen with a Ready state row at a generation, and when. Typeahead sends a query per keystroke; without the + /// memo every one would read the state row. Only Ready is remembered, so a department still building is re-read until + /// it is done, and a new generation (schema bump, protection change) is never answered from the memo. + /// + private static readonly ConcurrentDictionary ReadyDepartments = new ConcurrentDictionary(); + private readonly IGlobalSearchService _global; private readonly Lazy _invoicing; private readonly Lazy _bids; private readonly Lazy _contracts; private readonly Lazy _deploymentsService; private readonly Lazy _certifications; + private readonly Lazy _trainings; + private readonly Lazy _calendar; + private readonly Lazy _logs; + private readonly Lazy _mapping; + private readonly Lazy _shifts; + private readonly Lazy _preventionGate; + private readonly IRmsOccupanciesRepository _occupancies; private readonly ISystemActionsService _actions; private readonly IFeatureToggleService _featureToggles; private readonly IAuthorizationService _authorization; @@ -49,8 +68,18 @@ public UnifiedSearchService(IGlobalSearchService global, ISystemActionsService a IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings, ISearchProjectionsRepository projections, Lazy invoicing = null, Lazy bids = null, Lazy contracts = null, - Lazy deployments = null, Lazy certifications = null) + Lazy deployments = null, Lazy certifications = null, + Lazy trainings = null, Lazy calendar = null, Lazy logs = null, + Lazy mapping = null, Lazy shifts = null, + Lazy preventionGate = null, IRmsOccupanciesRepository occupancies = null) { + _preventionGate = preventionGate; + _occupancies = occupancies; + _trainings = trainings; + _calendar = calendar; + _logs = logs; + _mapping = mapping; + _shifts = shifts; _invoicing = invoicing; _bids = bids; _contracts = contracts; @@ -131,22 +160,32 @@ public async Task SearchAsync(UnifiedSearchRequest request, return Finish(result, watch); } - var types = AllowedTypes(request.EntityTypes, principal); + var types = await WithoutClosedModulesAsync(AllowedTypes(request.EntityTypes, principal), access); + var window = request.MaxCandidates > CandidateWindow + ? Math.Min(request.MaxCandidates, Math.Max(CandidateWindow, SearchConfig.MaxPageWindow)) + : CandidateWindow; var skip = Math.Max(0, request.Skip); - var take = Math.Max(1, Math.Min(100, request.Take)); - var needed = Math.Min(skip, CandidateWindow) + take; + // A page is at most 100 hits; an export (MaxCandidates past the default window) may take the whole window at once. + var take = Math.Max(1, Math.Min(request.MaxCandidates > CandidateWindow ? window : 100, request.Take)); + var needed = Math.Min(skip, window) + take; + var sort = SearchSortOrders.Normalize(request.Sort); + var snippetTerms = request.Prefix ? null : SearchSnippets.Terms(text); var dropped = 0; var authorized = new List(); var windowCoveredAll = true; + var stoppedEarly = false; if (types.Count > 0) { + // Activation runs on every search, not only while the index is missing: once any department had built the + // shared index, a department that had never searched before never got a state row and was never indexed. + result.IndexBuilding = !await EnsureStateAsync(principal.DepartmentId, access.GlobalGeneration, cancellationToken); + if (!_global.IsAvailable) { windowCoveredAll = false; result.Degraded = true; result.DegradedReason = "The search index is not available yet."; - await EnsureStateAsync(principal.DepartmentId, cancellationToken); } else { @@ -162,8 +201,12 @@ public async Task SearchAsync(UnifiedSearchRequest request, ViewerScopedEntityTypes = ViewerScopedTypes(principal), IncludeAdminOnly = principal.IsDepartmentAdmin, Prefix = request.Prefix, + FromUtc = request.FromUtc, + ToUtc = request.ToUtc, + Sort = sort, + MaxWindow = window, Skip = 0, - Take = CandidateWindow + Take = window }, cancellationToken); } catch (Exception ex) @@ -177,7 +220,6 @@ public async Task SearchAsync(UnifiedSearchRequest request, windowCoveredAll = false; result.Degraded = true; result.DegradedReason = "The search index is not available yet."; - await EnsureStateAsync(principal.DepartmentId, cancellationToken); } else { @@ -189,13 +231,17 @@ public async Task SearchAsync(UnifiedSearchRequest request, foreach (var hit in indexResult.Hits) { cancellationToken.ThrowIfCancellationRequested(); - // An incomplete window can never yield an authorized total; stop once the page is filled. - if (!windowCoveredAll && authorized.Count >= needed) + // An incomplete window can never yield an authorized total, and a caller that shows none (the command + // palette) does not need one: stop once the page is filled instead of authorizing every candidate. + if ((!windowCoveredAll || !request.CountTotal) && authorized.Count >= needed) + { + stoppedEarly = true; break; + } if (hit != null && types.Contains(hit.EntityType) && projections.TryGetValue(hit.ProjectionId ?? string.Empty, out var projection) && ProjectionIsCurrent(hit, projection, access) && await AuthorizeAsync(hit, access)) - authorized.Add(Map(projection, hit.Score)); + authorized.Add(Map(projection, hit.Score, snippetTerms)); else dropped++; } @@ -211,7 +257,16 @@ public async Task SearchAsync(UnifiedSearchRequest request, { try { - (recordHits, recordsTotal) = await FederateRecordsAsync(text, access, Math.Min(Math.Min(skip, CandidateWindow) + take, CandidateWindow), cancellationToken); + (recordHits, recordsTotal) = await FederateRecordsAsync(text, access, Math.Min(Math.Min(skip, window) + take, window), cancellationToken); + if (request.FromUtc.HasValue || request.ToUtc.HasValue) + { + // Records carry no date filter of their own; the range applies to the authorized hits, so the total + // stays a count of hits the caller may open. + var inRange = recordHits.Where(h => InRange(h.OccurredOn, request.FromUtc, request.ToUtc)).ToList(); + if (recordsTotal.HasValue) + recordsTotal = inRange.Count; + recordHits = inRange; + } } catch (Exception ex) { @@ -221,13 +276,18 @@ public async Task SearchAsync(UnifiedSearchRequest request, } // One sequence, index hits then the records federation, paged as a whole: special-casing the first page - // dropped the Records family from every later page. - result.Hits = authorized.Concat(recordHits).Skip(skip).Take(take).ToList(); + // dropped the Records family from every later page. A date ordering spans both families. + var combined = authorized.Concat(recordHits); + if (sort == SearchSortOrders.Newest) + combined = combined.OrderByDescending(h => h.OccurredOn ?? DateTime.MinValue); + else if (sort == SearchSortOrders.Oldest) + combined = combined.OrderBy(h => h.OccurredOn ?? DateTime.MaxValue); + result.Hits = combined.Skip(skip).Take(take).ToList(); // A raw index truncation flag also discloses unauthorized matches. Only expose authorized metadata. result.Truncated = false; // Totals only when they can be proven from authorized results (plan 2026-08-15 correction). - if (dropped == 0 && windowCoveredAll && recordsTotal.HasValue) + if (dropped == 0 && windowCoveredAll && !stoppedEarly && recordsTotal.HasValue) result.Total = authorized.Count + recordsTotal.Value; else result.Total = null; @@ -235,12 +295,55 @@ public async Task SearchAsync(UnifiedSearchRequest request, return Finish(result, watch); } + public async Task> GetSearchableEntityTypesAsync(SearchPrincipal principal, CancellationToken cancellationToken = default) + { + var types = new List(); + if (principal == null || principal.DepartmentId <= 0 || string.IsNullOrWhiteSpace(principal.UserId)) + return types; + if (!await FlagOnAsync(principal.DepartmentId)) + return types; + + var access = await LoadAccessAsync(principal); + if (access == null) + return types; + + types.AddRange(await WithoutClosedModulesAsync(AllowedTypes(null, access.Principal), access)); + try + { + if (await RecordsSearchableAsync(access.Principal)) + types.Add(SearchEntityTypes.Record); + } + catch (Exception ex) + { + Logging.LogException(ex, "Records search availability could not be determined."); + } + + return types; + } + private static UnifiedSearchResult Finish(UnifiedSearchResult result, Stopwatch watch) { result.QueryTimeMs = (int)watch.ElapsedMilliseconds; return result; } + /// Drops the families whose module is closed for the department in ways the claim set does not show (the Records occupancy module). + private async Task> WithoutClosedModulesAsync(List types, SearchAccess access) + { + if (types.Contains(SearchEntityTypes.Occupancy) && !await OccupancyModuleOpenAsync(access)) + types.Remove(SearchEntityTypes.Occupancy); + return types; + } + + private static bool InRange(DateTime? value, DateTime? fromUtc, DateTime? toUtc) + { + if (!fromUtc.HasValue && !toUtc.HasValue) + return true; + if (!value.HasValue) + return false; + return (!fromUtc.HasValue || value.Value >= fromUtc.Value) && (!toUtc.HasValue || value.Value <= toUtc.Value); + } + private static bool WantsType(List requested, string type) { return requested == null || requested.Count == 0 || requested.Any(t => string.Equals(t, type, StringComparison.OrdinalIgnoreCase)); @@ -278,6 +381,19 @@ void Add(string type, string resource, string module = null) if (WantsType(requested, SearchEntityTypes.Deployment)) allowed.Add(SearchEntityTypes.Deployment); Add(SearchEntityTypes.CertificationType, "Certifications"); + // Operations reference families (plan R3 Tier 2): the claim and module switch of each family's own page. POIs have + // no claim of their own: the mapping pages admit every member. + Add(SearchEntityTypes.Log, "Log", SystemActionModules.Logs); + Add(SearchEntityTypes.Protocol, "Protocols"); + Add(SearchEntityTypes.Training, "Training", SystemActionModules.Training); + Add(SearchEntityTypes.CalendarEvent, "Schedule", SystemActionModules.Calendar); + Add(SearchEntityTypes.Shift, "Shift", SystemActionModules.Shifts); + Add(SearchEntityTypes.Group, "GenericGroup"); + // Occupancies sit behind Record_View like their pages; the Records cutover and the module flag are checked by + // WithoutClosedModulesAsync, which is asynchronous. + Add(SearchEntityTypes.Occupancy, "Record"); + if (WantsType(requested, SearchEntityTypes.Poi) && principal.ModuleEnabled(SystemActionModules.Mapping)) + allowed.Add(SearchEntityTypes.Poi); return allowed; } @@ -292,7 +408,7 @@ private static List ViewerScopedTypes(SearchPrincipal principal) => ? null : new List { SearchEntityTypes.Deployment }; - private static UnifiedSearchHit Map(SearchProjection hit, float score) + private static UnifiedSearchHit Map(SearchProjection hit, float score, IReadOnlyList snippetTerms = null) { IDictionary metadata = new Dictionary(); if (!string.IsNullOrWhiteSpace(hit.MetadataJson)) @@ -312,6 +428,8 @@ private static UnifiedSearchHit Map(SearchProjection hit, float score) OccurredOn = hit.OccurredOn, Category = hit.Category, Status = hit.Status, + Snippet = snippetTerms == null || snippetTerms.Count == 0 ? null + : SearchSnippets.Build(hit.SearchText, snippetTerms) ?? SearchSnippets.Build(hit.Summary, snippetTerms), Metadata = metadata }; } @@ -320,15 +438,7 @@ private static UnifiedSearchHit Map(SearchProjection hit, float score) { var principal = access.Principal; var hits = new List(); - if (!principal.IsDepartmentAdmin && !principal.HasResourceClaim("Record", "View")) - return (hits, 0); - if (_recordsSearch == null || !_recordsSearch.IsAvailable) - return (hits, 0); - - var module = await _recordsCutover.GetModuleStateAsync(principal.DepartmentId); - if (module == null || !module.FlagEnabled || !module.Activated) - return (hits, 0); - if (!await _recordsAuthorization.IsActiveMemberAsync(principal.UserId, principal.DepartmentId)) + if (!await RecordsSearchableAsync(principal)) return (hits, 0); List visibleGroups = null; @@ -393,13 +503,44 @@ private static UnifiedSearchHit Map(SearchProjection hit, float score) return (hits, dropped == 0 && !search.Truncated && search.Hits.Count == search.Total ? hits.Count : (int?)null); } - private async Task EnsureStateAsync(int departmentId, CancellationToken cancellationToken) + /// The caller may search Records: the view claim (or admin), an activated Records module, current membership and a live records index. + private async Task RecordsSearchableAsync(SearchPrincipal principal) + { + if (!principal.IsDepartmentAdmin && !principal.HasResourceClaim("Record", "View")) + return false; + if (_recordsSearch == null || !_recordsSearch.IsAvailable) + return false; + + var module = await _recordsCutover.GetModuleStateAsync(principal.DepartmentId); + if (module == null || !module.FlagEnabled || !module.Activated) + return false; + return await _recordsAuthorization.IsActiveMemberAsync(principal.UserId, principal.DepartmentId); + } + + /// + /// Makes sure worker 70 sweeps the department: creates its state row on first use (lazy activation). Returns true when + /// the department's index is built (Ready) at the current generation; false while it is queued, rebuilding, failed, + /// just created, or still built at an older generation (the query filters on the current one, so it finds nothing yet). + /// + private async Task EnsureStateAsync(int departmentId, string generation, CancellationToken cancellationToken) { + if (ReadyDepartments.TryGetValue(departmentId, out var seen) && DateTime.UtcNow - seen.Seen < ReadyMemo && + string.Equals(seen.Generation, generation, StringComparison.Ordinal)) + return true; + try { var existing = await _states.GetAsync(SearchIndexNames.Global, departmentId); if (existing != null) - return; + { + var ready = existing.State == (int)SearchIndexBuildState.Ready && !existing.RebuildRequestedOn.HasValue && + (string.IsNullOrEmpty(existing.Generation) || string.Equals(existing.Generation, generation, StringComparison.Ordinal)); + if (ready) + ReadyDepartments[departmentId] = (DateTime.UtcNow, generation); + else + ReadyDepartments.TryRemove(departmentId, out _); + return ready; + } var now = DateTime.UtcNow; // A department's first searches arrive together (typeahead sends one per keystroke) and all see no row // above, so the create has to be conditional or every request but one fails on the unique index. @@ -414,13 +555,18 @@ await _states.InsertIfMissingAsync(new SearchIndexState CreatedOn = now, ModifiedOn = now }, cancellationToken); + return false; } catch (Exception ex) { Logging.LogException(ex, $"Could not create the search index state row for department {departmentId}."); + return false; } } + /// Test seam: forget every department this process has seen Ready. + public static void ResetReadyMemo() => ReadyDepartments.Clear(); + private async Task FlagOnAsync(int departmentId) { try { return await _featureToggles.IsEnabledAsync(FeatureFlagKeys.SearchUnified, departmentId); } diff --git a/Core/Resgrid.Services/SecurityNoticeService.cs b/Core/Resgrid.Services/SecurityNoticeService.cs new file mode 100644 index 000000000..008324637 --- /dev/null +++ b/Core/Resgrid.Services/SecurityNoticeService.cs @@ -0,0 +1,235 @@ +using System; +using System.Linq; +using System.Net.Mail; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Localization.Areas.User.SystemMessages; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class SecurityNoticeService : ISecurityNoticeService + { + private static readonly TimeSpan Lease = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MaximumBackoff = TimeSpan.FromHours(6); + + private readonly ISecurityNoticeRepository _notices; + private readonly IIdentityUserRepository _identityUsers; + private readonly IUserProfileService _profiles; + private readonly IEmailSender _email; + private readonly ISystemAuditsService _audits; + private readonly TimeProvider _time; + private readonly string _owner = $"{Environment.MachineName}:{Guid.NewGuid():N}"; + + public SecurityNoticeService(ISecurityNoticeRepository notices, IIdentityUserRepository identityUsers, IUserProfileService profiles, + IEmailSender email, ISystemAuditsService audits, TimeProvider time) + { + _notices = notices; + _identityUsers = identityUsers; + _profiles = profiles; + _email = email; + _audits = audits; + _time = time; + } + + private static int MaxAttempts => Math.Max(1, TwoFactorConfig.SecurityNoticeMaxAttempts); + + public async Task QueueAsync(SecurityNoticeRequest request, CancellationToken cancellationToken = default) + { + if (!TwoFactorConfig.SecurityNoticesEnabled || request == null || string.IsNullOrWhiteSpace(request.UserId)) + return; + + SecurityNotice notice; + try + { + var now = _time.GetUtcNow().UtcDateTime; + notice = new SecurityNotice + { + SecurityNoticeId = Guid.NewGuid().ToString(), + UserId = request.UserId, + Kind = (int)request.Kind, + OccurredOnUtc = request.OccurredOnUtc ?? now, + ClientApplication = request.ClientApplication == null ? null : (int)request.ClientApplication.Value, + InstallationLabel = Limit(request.InstallationLabel, 256), + Region = Limit(request.Region, 256), + State = (int)SecurityNoticeState.Pending, + NextAttemptOnUtc = now, + CreatedOnUtc = now + }; + await _notices.InsertAsync(notice, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The change already committed; a notice that could not even be queued is an alert, not a rollback. + Logging.LogException(ex, $"A security notice ({request.Kind}) could not be queued."); + return; + } + + // At once where possible; worker 13 retries whatever this does not send. + try + { + var now = _time.GetUtcNow().UtcDateTime; + if (await _notices.TryClaimAsync(notice.SecurityNoticeId, _owner, now, now.Add(Lease), cancellationToken)) + await DeliverAsync(notice, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A security notice could not be sent now; it will be retried."); + } + } + + public async Task QueueOnceAsync(SecurityNoticeRequest request, TimeSpan within, CancellationToken cancellationToken = default) + { + if (!TwoFactorConfig.SecurityNoticesEnabled) + return; + + try + { + if (request != null && await _notices.ExistsSinceAsync(request.UserId, request.Kind, _time.GetUtcNow().UtcDateTime - within, cancellationToken)) + return; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // Better a duplicate notice than a missing one. + Logging.LogException(ex, "Earlier security notices could not be read; the notice is queued anyway."); + } + + await QueueAsync(request, cancellationToken); + } + + public async Task DeliverDueAsync(int batchSize, CancellationToken cancellationToken = default) + { + // Turning the gate off stops sending too; anything already queued waits for it. + if (!TwoFactorConfig.SecurityNoticesEnabled) + return 0; + + var now = _time.GetUtcNow().UtcDateTime; + var due = await _notices.ClaimDueAsync(_owner, now, now.Add(Lease), batchSize, cancellationToken); + var sent = 0; + foreach (var notice in due) + { + try + { + if (await DeliverAsync(notice, cancellationToken)) + sent++; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A security notice retry failed; it stays queued."); + } + } + + return sent; + } + + /// Sends one claimed notice and records the result; true when it was sent. + private async Task DeliverAsync(SecurityNotice notice, CancellationToken cancellationToken) + { + var user = await _identityUsers.GetByIdAsync(notice.UserId); + if (user == null || string.IsNullOrWhiteSpace(user.Email) || !user.Email.Contains('@')) + { + await FailAsync(notice, "no_destination", cancellationToken); + return false; + } + + bool delivered; + try + { + var profile = await _profiles.GetProfileByUserIdAsync(notice.UserId); + using var mail = Compose(notice, user.Email, profile?.FirstName, profile?.Language); + delivered = await _email.SendEmail(mail); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A security notice could not be sent."); + delivered = false; + } + + if (delivered) + return await _notices.MarkSentAsync(notice.SecurityNoticeId, _owner, _time.GetUtcNow().UtcDateTime, cancellationToken); + + if (notice.Attempts + 1 >= MaxAttempts) + { + await FailAsync(notice, "delivery_failed", cancellationToken); + return false; + } + + // 1, 2, 4, 8... minutes, capped at six hours. + var backoff = TimeSpan.FromMinutes(Math.Pow(2, Math.Min(notice.Attempts, 12))); + await _notices.MarkRetryAsync(notice.SecurityNoticeId, _owner, _time.GetUtcNow().UtcDateTime.Add(backoff < MaximumBackoff ? backoff : MaximumBackoff), + "delivery_failed", cancellationToken); + return false; + } + + /// A notice that can never be sent is logged as an error and audited; the change it reports stands. + private async Task FailAsync(SecurityNotice notice, string reason, CancellationToken cancellationToken) + { + if (!await _notices.MarkFailedAsync(notice.SecurityNoticeId, _owner, reason, cancellationToken)) + return; + + Logging.LogError($"Security notice {notice.SecurityNoticeId} ({notice.NoticeKind}) for user {notice.UserId} was not delivered: {reason}."); + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Worker, + Type = (int)SystemAuditTypes.SecurityNoticeFailed, + UserId = notice.UserId, + Successful = false, + ServerName = Environment.MachineName, + Data = $"Security notice {notice.SecurityNoticeId} ({notice.NoticeKind}) was not delivered: {reason}." + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A failed security notice could not be audited."); + } + } + + /// + /// The notice in the recipient's language: what happened, when and where, and what to do if it was not them. It + /// carries no link that acts on the account, no code, and nothing from the account beyond the event itself. + /// + internal static MailMessage Compose(SecurityNotice notice, string emailAddress, string firstName, string culture) + { + var where = notice.ClientApplication is int client + ? string.Join(", ", new[] { PasskeyService.ClientLabel((UserSessionClientApplication)client), notice.InstallationLabel, notice.Region } + .Where(part => !string.IsNullOrWhiteSpace(part))) + : notice.Region; + var lines = new System.Collections.Generic.List + { + string.IsNullOrWhiteSpace(firstName) + ? SystemMessagesResources.Get("SecurityNoticeGreetingNoName", culture) + : SystemMessagesResources.Get("SecurityNoticeGreeting", culture, firstName.Trim()), + "", + SystemMessagesResources.Get("SecurityNotice" + notice.NoticeKind, culture), + "", + SystemMessagesResources.Get("SecurityNoticeWhen", culture, notice.OccurredOnUtc.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)) + }; + if (!string.IsNullOrWhiteSpace(where)) + lines.Add(SystemMessagesResources.Get("SecurityNoticeWhere", culture, where)); + lines.Add(""); + lines.Add(SystemMessagesResources.Get("SecurityNoticeNotYou", culture, $"{SystemBehaviorConfig.ResgridBaseUrl?.TrimEnd('/')}/Account/ForgotPassword")); + lines.Add(""); + lines.Add(SystemMessagesResources.Get("SecurityNoticeFooter", culture)); + + var mail = new MailMessage(); + mail.To.Add(emailAddress); + mail.From = new MailAddress(OutboundEmailServerConfig.FromMail, "Resgrid"); + mail.Subject = SystemMessagesResources.Get("SecurityNoticeSubject", culture); + mail.Body = string.Join(Environment.NewLine, lines); + mail.IsBodyHtml = false; + return mail; + } + + private static string Limit(string value, int length) => + string.IsNullOrWhiteSpace(value) ? null : value.Length <= length ? value : value[..length]; + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 4ce3acbb4..d3833058d 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -284,6 +284,28 @@ protected override void Load(ContainerBuilder builder) // SSO / Security Policy builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + + // Passkey plan Phase 1 slice 2: MFA evidence, single-use ceremony challenges and the per-client relying parties. + // The registry parses static config once; the gates only report ON when that configuration validated. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.Register(_ => new RelyingPartyRegistry()).As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + // Needs IPasskeyProvider from Resgrid.Providers.Authentication, which only the ceremony hosts (Web, API) load. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.Register(_ => new SsoReturnTargetRegistry()).As().SingleInstance(); + // Needs IOidcProviderClient from Resgrid.Providers.Authentication, loaded by Web and API. + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -298,6 +320,8 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + // Dedicated session-assertion key (passkey workbook section 6.2): Web and API mint, the broker validates. + builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/SessionConnectionRegistry.cs b/Core/Resgrid.Services/SessionConnectionRegistry.cs new file mode 100644 index 000000000..eaadbdcc4 --- /dev/null +++ b/Core/Resgrid.Services/SessionConnectionRegistry.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// The open SignalR connections of one host, by the session each belongs to (passkey workbook section 12, slice 16). + /// Hub filters register and remove connections; the host's sweep closes every connection whose session ended, locked + /// or passed its idle deadline, so a connection that never invokes anything stops receiving broadcasts too. + /// + public sealed class SessionConnectionRegistry + { + private readonly ConcurrentDictionary _connections = new(StringComparer.Ordinal); + + public int Count => _connections.Count; + + public void Register(string connectionId, string sessionId, Action abort) + { + if (!string.IsNullOrWhiteSpace(connectionId) && !string.IsNullOrWhiteSpace(sessionId) && abort != null) + _connections[connectionId] = (sessionId, abort); + } + + public void Unregister(string connectionId) + { + if (!string.IsNullOrWhiteSpace(connectionId)) + _connections.TryRemove(connectionId, out _); + } + + /// Closes this host's connections of one session now. Returns how many were closed. + public int CloseSession(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + return 0; + + return _connections.Where(c => string.Equals(c.Value.SessionId, sessionId, StringComparison.Ordinal)).ToArray().Count(Close); + } + + /// Checks every distinct session behind an open connection, in one batched read, and closes the unusable ones. + public async Task SweepAsync(IUserSessionService sessions, CancellationToken cancellationToken = default) + { + var snapshot = _connections.ToArray(); + if (snapshot.Length == 0) + return 0; + + var unusable = await sessions.GetUnusableSessionIdsAsync(snapshot.Select(c => c.Value.SessionId).Distinct(StringComparer.Ordinal).ToList(), + cancellationToken); + return snapshot.Where(c => unusable.Contains(c.Value.SessionId)).Count(Close); + } + + private bool Close(System.Collections.Generic.KeyValuePair connection) + { + if (!_connections.TryRemove(connection.Key, out _)) + return false; + + try + { + connection.Value.Abort(); + } + catch (Exception ex) + { + // A connection that is already going away; nothing else to do for it. + Logging.LogException(ex, "A SignalR connection could not be closed."); + } + + return true; + } + } +} diff --git a/Core/Resgrid.Services/SharedSessionService.cs b/Core/Resgrid.Services/SharedSessionService.cs new file mode 100644 index 000000000..bb9c9f439 --- /dev/null +++ b/Core/Resgrid.Services/SharedSessionService.cs @@ -0,0 +1,221 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public class SharedSessionService : ISharedSessionService + { + private readonly IUserSessionsRepository _sessions; + private readonly IUserSessionService _userSessions; + private readonly IMfaEvidenceService _evidence; + private readonly IDepartmentSsoService _departmentSso; + private readonly ISystemAuditsService _audits; + private readonly IMfaActivityService _activity; + private readonly TimeProvider _time; + + public SharedSessionService(IUserSessionsRepository sessions, IUserSessionService userSessions, IMfaEvidenceService evidence, + IDepartmentSsoService departmentSso, ISystemAuditsService audits, IMfaActivityService activity, TimeProvider time) + { + _activity = activity; + _sessions = sessions; + _userSessions = userSessions; + _evidence = evidence; + _departmentSso = departmentSso; + _audits = audits; + _time = time; + } + + public async Task GetStatusAsync(UserSession session, CancellationToken cancellationToken = default) + { + if (session == null) + return null; + if (!session.SharedMode) + return new SharedSessionStatus { ClientApplication = (UserSessionClientApplication)session.ClientApplication }; + + var policy = await PolicyAsync(session, cancellationToken); + return new SharedSessionStatus + { + Shared = true, + Locked = session.IsLocked, + LockVersion = session.LockVersion, + LockReason = session.IsLocked && session.LockReason != null ? (SharedSessionLockReason)session.LockReason.Value : null, + IdleLockMinutes = SharedSessionRules.EffectiveIdleLockMinutes(session, policy), + IdleLocksOnUtc = session.IsLocked ? null : SharedSessionRules.IdleLocksOn(session, policy), + ShiftEndsOnUtc = SharedSessionRules.ShiftEndsOn(session, policy), + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + InstallationLabel = session.DeviceName + }; + } + + public async Task LockAsync(UserSession session, SharedSessionRequestInfo request, CancellationToken cancellationToken = default) + { + if (session == null || session.State != (int)UserSessionState.Active) + return SharedSessionTransition.Of(SharedSessionOutcome.SessionEnded); + if (!session.SharedMode) + return SharedSessionTransition.Of(SharedSessionOutcome.NotShared); + + // Two tries: a concurrent unlock between reading the row and locking it moves the version once. + var current = session; + for (var attempt = 0; attempt < 2; attempt++) + { + if (current.IsLocked) + return SharedSessionTransition.Of(SharedSessionOutcome.Succeeded, current.LockVersion); + + var now = _time.GetUtcNow().UtcDateTime; + if (await _sessions.TryLockAsync(current.UserSessionId, current.LockVersion, (int)SharedSessionLockReason.Explicit, now, cancellationToken) == 1) + { + current.IsLocked = true; + current.LockVersion++; + current.LockedOnUtc = now; + current.LockReason = (int)SharedSessionLockReason.Explicit; + await AuditAsync(SystemAuditTypes.SharedSessionLocked, current, request, true, "locked", "explicit", cancellationToken); + // Its realtime connections stop now; the app reconnects after the unlock. + await _userSessions.CloseConnectionsAsync(current.UserSessionId); + return SharedSessionTransition.Of(SharedSessionOutcome.Succeeded, current.LockVersion); + } + + current = await _sessions.GetByIdAsync(session.UserSessionId); + if (current == null || current.State != (int)UserSessionState.Active || + !string.Equals(current.UserId, session.UserId, StringComparison.OrdinalIgnoreCase)) + return SharedSessionTransition.Of(SharedSessionOutcome.SessionEnded); + } + + return current.IsLocked + ? SharedSessionTransition.Of(SharedSessionOutcome.Succeeded, current.LockVersion) + : SharedSessionTransition.Of(SharedSessionOutcome.LockChanged, current.LockVersion); + } + + public async Task CanUnlockAsync(UserSession session, CancellationToken cancellationToken = default) + { + if (session == null || session.State != (int)UserSessionState.Active || session.ExpiresOn <= _time.GetUtcNow().UtcDateTime) + return SharedSessionOutcome.SessionEnded; + if (!session.SharedMode) + return SharedSessionOutcome.NotShared; + if (!session.IsLocked) + return SharedSessionOutcome.NotLocked; + + // Unlock resumes the first factor this session began with, so a department that has since required SSO gets an SSO + // sign-in instead (plan section 12.5.3), exactly as a new password sign-in would be refused. + if (session.DepartmentId.HasValue && + session.AuthenticationMethod != (int)UserSessionAuthenticationMethod.OidcSso && + session.AuthenticationMethod != (int)UserSessionAuthenticationMethod.SamlSso) + { + var policy = await PolicyAsync(session, cancellationToken); + if (policy?.RequireSso == true && + (await _departmentSso.GetSsoConfigsForDepartmentAsync(session.DepartmentId.Value, cancellationToken) ?? Enumerable.Empty()) + .Any(c => c.IsEnabled)) + return SharedSessionOutcome.SsoReauthenticationRequired; + } + + return SharedSessionOutcome.Succeeded; + } + + public async Task UnlockAsync(UserSession session, long expectedLockVersion, MfaEvidenceMethod method, string factorReference, + DateTime verifiedOnUtc, SharedSessionRequestInfo request, CancellationToken cancellationToken = default) + { + if (session == null || !session.SharedMode) + return SharedSessionTransition.Of(session == null ? SharedSessionOutcome.SessionEnded : SharedSessionOutcome.NotShared); + + var now = _time.GetUtcNow().UtcDateTime; + if (await _sessions.TryUnlockAsync(session.UserId, session.UserSessionId, expectedLockVersion, now, cancellationToken) != 1) + { + var current = await _sessions.GetByIdAsync(session.UserSessionId); + if (current == null || current.State != (int)UserSessionState.Active || current.ExpiresOn <= now) + return SharedSessionTransition.Of(SharedSessionOutcome.SessionEnded); + return SharedSessionTransition.Of(current.IsLocked ? SharedSessionOutcome.LockChanged : SharedSessionOutcome.NotLocked, current.LockVersion); + } + + session.IsLocked = false; + session.LastOperatorActivityOn = now; + + // Unlock evidence is a real second factor for this operator, recorded after the lock it answers. Protected data may + // reuse it only where the department's AcceptRecentUnlockMfaForAdp allows; the first-factor time is untouched. + try + { + await _evidence.RecordAsync(session.UserId, MfaEvidence.TrackedSessionKey(session.UserSessionId), + (UserSessionClientApplication)session.ClientApplication, MfaEvidenceKind.SecondFactor, method, MfaEvidencePurpose.SharedUnlock, + verifiedOnUtc, session.AuthenticationGeneration, session.DepartmentId, factorReference, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The unlock stands; the operator verifies again for anything that needs recent MFA. + Logging.LogException(ex, "Shared session unlock evidence could not be recorded."); + } + + await AuditAsync(SystemAuditTypes.SharedSessionUnlocked, session, request, true, "unlocked", MfaMethodNames.From(method), cancellationToken); + return SharedSessionTransition.Of(SharedSessionOutcome.Succeeded, session.LockVersion); + } + + public async Task RecordFailedUnlockAsync(UserSession session, string method, SharedSessionRequestInfo request, CancellationToken cancellationToken = default) + { + if (session == null) + return; + + await AuditAsync(SystemAuditTypes.SharedSessionUnlocked, session, request, false, "unlock failed", method, cancellationToken); + await _activity.RecordAsync(new MfaActivityEntry + { + UserId = session.UserId, Method = MfaMethodNames.Parse(method), Purpose = MfaEvidencePurpose.SharedUnlock, Successful = false, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, InstallationLabel = session.DeviceName, SharedMode = true, + DepartmentId = session.DepartmentId, SessionId = session.UserSessionId + }, cancellationToken); + } + + public async Task EndShiftAsync(UserSession session, bool switchOperator, SharedSessionRequestInfo request, + CancellationToken cancellationToken = default) + { + if (session == null) + return SharedSessionTransition.Of(SharedSessionOutcome.SessionEnded); + if (!session.SharedMode) + return SharedSessionTransition.Of(SharedSessionOutcome.NotShared); + + var result = await _userSessions.RevokeSessionAsync(session.UserId, session.UserId, session.UserSessionId, + switchOperator ? UserSessionRevocationReason.OperatorSwitched : UserSessionRevocationReason.ShiftEnded, cancellationToken); + if (result.RevokedSessionCount > 0) + await AuditAsync(SystemAuditTypes.SharedSessionEnded, session, request, true, "ended", switchOperator ? "switch operator" : "end shift", + cancellationToken); + + // Ended either way: a session another request already ended is just as finished. + return SharedSessionTransition.Of(SharedSessionOutcome.Succeeded, session.LockVersion); + } + + private async Task PolicyAsync(UserSession session, CancellationToken cancellationToken) => + session.DepartmentId.HasValue ? await _departmentSso.GetSecurityPolicyForDepartmentAsync(session.DepartmentId.Value, cancellationToken) : null; + + private async Task AuditAsync(SystemAuditTypes type, UserSession session, SharedSessionRequestInfo request, bool successful, string action, + string detail, CancellationToken cancellationToken) + { + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)(request?.AuditSystem ?? SystemAuditSystems.Api), + Type = (int)type, + DepartmentId = session.DepartmentId, + UserId = session.UserId, + Username = request?.UserName, + TargetUserId = session.UserId, + SessionId = SharedSessionAudit.SessionSuffix(session.UserSessionId), + Successful = successful, + IpAddress = request?.IpAddress, + ServerName = Environment.MachineName, + CorrelationId = request?.CorrelationId, + Data = SharedSessionAudit.Describe(action, session, detail), + LoggedOn = _time.GetUtcNow().UtcDateTime + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The transition already committed; a missing audit row must not report it as failed. + Logging.LogException(ex, "Shared session audit failed."); + } + } + } +} diff --git a/Core/Resgrid.Services/ShiftsService.cs b/Core/Resgrid.Services/ShiftsService.cs index 4cf2260fd..e3f9882cf 100644 --- a/Core/Resgrid.Services/ShiftsService.cs +++ b/Core/Resgrid.Services/ShiftsService.cs @@ -9,6 +9,7 @@ using Resgrid.Model.Providers; using Resgrid.Model.Queue; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -32,6 +33,7 @@ public partial class ShiftsService : IShiftsService private readonly IShiftGroupRolesRepository _shiftGroupRolesRepository; private readonly IEventAggregator _eventAggregator; private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly Lazy _searchProjections; public ShiftsService(IShiftsRepository shiftsRepository, IShiftPersonRepository shiftPersonRepository, IShiftDaysRepository shiftDaysRepository, IShiftGroupsRepository shiftGroupsRepository, @@ -39,8 +41,9 @@ public ShiftsService(IShiftsRepository shiftsRepository, IShiftPersonRepository IShiftSignupTradeUserRepository shiftSignupTradeUserRepository, IShiftSignupTradeUserShiftsRepository shiftSignupTradeUserShiftsRepository, IShiftStaffingRepository shiftStaffingRepository, IShiftStaffingPersonRepository shiftStaffingPersonRepository, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IShiftGroupAssignmentsRepository shiftGroupAssignmentsRepository, IShiftGroupRolesRepository shiftGroupRolesRepositor, - IEventAggregator eventAggregator, IDepartmentSettingsService departmentSettingsService) + IEventAggregator eventAggregator, IDepartmentSettingsService departmentSettingsService, Lazy searchProjections = null) { + _searchProjections = searchProjections; _shiftsRepository = shiftsRepository; _shiftPersonRepository = shiftPersonRepository; _shiftDaysRepository = shiftDaysRepository; @@ -128,6 +131,7 @@ public async Task PopulateShiftData(Shift shift, bool getDepartment, bool } } + if (_searchProjections != null && saved != null) await _searchProjections.Value.ProjectShiftAsync(saved, cancellationToken); return saved; } @@ -136,7 +140,9 @@ public async Task PopulateShiftData(Shift shift, bool getDepartment, bool if (shift == null) return null; - return await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken, true); + var saved = await _shiftsRepository.SaveOrUpdateAsync(shift, cancellationToken, true); + if (_searchProjections != null && saved != null) await _searchProjections.Value.ProjectShiftAsync(saved, cancellationToken); + return saved; } public async Task UpdateShiftStartDayAsync(Shift shift, DateTime startDay, CancellationToken cancellationToken = default(CancellationToken)) @@ -260,7 +266,10 @@ public async Task> GetShiftGroupsForShift(int shiftId) foreach (var signup in signups ?? Enumerable.Empty()) await ReleaseTradeReferencesToSignupAsync(signup.ShiftSignupId, cancellationToken); - return await _shiftsRepository.DeleteAsync(shift, cancellationToken); + var deleted = await _shiftsRepository.DeleteAsync(shift, cancellationToken); + if (deleted && _searchProjections != null) + await _searchProjections.Value.RemoveAsync(shift.DepartmentId, SearchEntityTypes.Shift, shift.ShiftId.ToString(), cancellationToken); + return deleted; } public async Task DeleteShiftGroupsByGroupIdAsync(int departmentGroupId, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Core/Resgrid.Services/SsoBrokerService.cs b/Core/Resgrid.Services/SsoBrokerService.cs new file mode 100644 index 000000000..eed816d56 --- /dev/null +++ b/Core/Resgrid.Services/SsoBrokerService.cs @@ -0,0 +1,826 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.IdentityModel.Tokens; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + public sealed class SsoBrokerService : ISsoBrokerService + { + private const string SamlRelayPrefix = "rgsso."; + private const int MaxSecretLength = 128; + private static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(2); + + private readonly ISsoLoginTransactionRepository _transactions; + private readonly IDepartmentSsoService _departmentSso; + private readonly IDepartmentsService _departments; + private readonly IIdentityUserRepository _identityUsers; + private readonly IEncryptionService _encryption; + private readonly IOidcProviderClient _oidc; + private readonly ISsoReturnTargetRegistry _returnTargets; + private readonly IBrokerReplayRepository _replay; + private readonly IMfaPolicyService _policy; + private readonly TimeProvider _time; + + public SsoBrokerService(ISsoLoginTransactionRepository transactions, IDepartmentSsoService departmentSso, IDepartmentsService departments, + IIdentityUserRepository identityUsers, IEncryptionService encryption, IOidcProviderClient oidc, ISsoReturnTargetRegistry returnTargets, + IBrokerReplayRepository replay, IMfaPolicyService policy, TimeProvider time) + { + _policy = policy; + _transactions = transactions; + _departmentSso = departmentSso; + _departments = departments; + _identityUsers = identityUsers; + _encryption = encryption; + _oidc = oidc; + _returnTargets = returnTargets; + _replay = replay; + _time = time; + } + + private static TimeSpan Lifetime => TimeSpan.FromSeconds(Math.Max(60, SsoConfig.BrokeredTransactionLifetimeSeconds)); + private static TimeSpan CodeLifetime => TimeSpan.FromSeconds(Math.Max(10, SsoConfig.BrokeredCodeLifetimeSeconds)); + private static TimeSpan ReauthenticationMaxAge => TimeSpan.FromSeconds(Math.Max(30, SsoConfig.ReauthenticationMaxAgeSeconds)); + + public bool IsEnabled => SsoConfig.BrokeredSsoEnabled && _returnTargets.IsReady; + + public string OidcRedirectUri => $"{SystemBehaviorConfig.ResgridApiBaseUrl?.TrimEnd('/')}{SsoConfig.OidcCallbackPath}"; + + public bool SupportsBrokered(DepartmentSsoConfig config) + { + if (config == null || !config.IsEnabled) + return false; + + return (SsoProviderType)config.SsoProviderType switch + { + SsoProviderType.Oidc => IsHttps(config.Authority) && !string.IsNullOrWhiteSpace(config.ClientId), + SsoProviderType.Saml2 => IsHttps(config.IdpSsoUrl) && !string.IsNullOrWhiteSpace(config.EntityId) && + !string.IsNullOrWhiteSpace(config.AssertionConsumerServiceUrl) && !string.IsNullOrWhiteSpace(config.EncryptedIdpCertificate), + _ => false + }; + } + + public string DepartmentTokenFor(Department department) => + department == null ? null : _encryption.Encrypt($"{department.DepartmentId}:{department.Code}"); + + public async Task ResolveDepartmentAsync(string departmentToken, string departmentCode, string username, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(departmentToken)) + { + try + { + var parts = _encryption.Decrypt(departmentToken).Split(':'); + if (parts.Length >= 2 && int.TryParse(parts[0], out var departmentId)) + { + var department = await _departments.GetDepartmentByIdAsync(departmentId); + if (department != null && string.Equals(department.Code, string.Join(":", parts.Skip(1)), StringComparison.Ordinal)) + return department; + } + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // An unreadable token is simply not a department; the other identifiers may still resolve one. + } + } + + if (!string.IsNullOrWhiteSpace(departmentCode)) + return await _departments.GetDepartmentByNameAsync(departmentCode); + + if (!string.IsNullOrWhiteSpace(username)) + { + var user = await _identityUsers.GetByUserNameAsync(username) ?? await _identityUsers.GetByEmailAsync(username); + if (user != null) + return await _departments.GetDepartmentByUserIdAsync(user.Id); + } + + return null; + } + + // ── Begin ───────────────────────────────────────────────────────────────────── + + public async Task BeginAsync(SsoBeginRequest request, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + return SsoBeginResult.Of(SsoBrokerOutcome.Unavailable); + if (request == null || request.DepartmentId <= 0 || !IsBindable(request)) + return SsoBeginResult.Of(SsoBrokerOutcome.InvalidRequest); + + if (!_returnTargets.IsAllowed(request.ClientApplication, request.ReturnTarget)) + return SsoBeginResult.Of(SsoBrokerOutcome.ReturnTargetNotAllowed); + if (!string.Equals(request.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !IsS256Challenge(request.CodeChallenge) || + request.ClientState?.Length > 512 || request.Platform?.Length > 32) + return SsoBeginResult.Of(SsoBrokerOutcome.InvalidRequest); + + try + { + var config = (await _departmentSso.GetSsoConfigsForDepartmentAsync(request.DepartmentId, cancellationToken))?.FirstOrDefault(c => c.IsEnabled); + if (!SupportsBrokered(config)) + return SsoBeginResult.Of(SsoBrokerOutcome.Unavailable); + + // Which provider step-up mapping, if any, this round trip asks for (plan section 7.8). + var mapping = await MappingToRequestAsync(request, config, cancellationToken); + if (mapping == null && request.Purpose is SsoTransactionPurpose.StepUp or SsoTransactionPurpose.AdpStepUp or SsoTransactionPurpose.MappingTest) + return SsoBeginResult.Of(SsoBrokerOutcome.Unavailable); + + var now = _time.GetUtcNow().UtcDateTime; + var bound = request.Purpose != SsoTransactionPurpose.Login; + var transaction = new SsoLoginTransaction + { + SsoLoginTransactionId = Guid.NewGuid().ToString(), + Purpose = (int)request.Purpose, + DepartmentId = request.DepartmentId, + DepartmentSsoConfigId = config.DepartmentSsoConfigId, + ProviderType = config.SsoProviderType, + ClientApplication = (int)request.ClientApplication, + Platform = request.Platform, + SharedInstallation = request.SharedInstallation, + ReturnTarget = request.ReturnTarget, + ClientState = request.ClientState, + CodeChallenge = request.CodeChallenge, + Operation = request.Purpose == SsoTransactionPurpose.StepUp ? request.Operation : null, + LoginTransactionId = request.Purpose == SsoTransactionPurpose.StepUp ? request.LoginTransactionId : null, + FederatedMappingVersion = mapping == null ? null : config.FederatedMfaMappingVersion, + SessionId = bound ? request.SessionId : null, + ExpectedUserId = bound ? request.UserId : null, + AuthenticationGeneration = bound ? request.AuthenticationGeneration : null, + CreatedOnUtc = now, + ExpiresOnUtc = now.Add(Lifetime), + State = (int)SsoLoginTransactionState.Pending + }; + + // Reauthentication must be recent; a provider step-up must happen now, with MFA (max_age=0, ForceAuthn). + var forceAuthn = request.Purpose is SsoTransactionPurpose.Reauthentication or SsoTransactionPurpose.StepUp or SsoTransactionPurpose.AdpStepUp or + SsoTransactionPurpose.MappingTest; + var maxAge = request.Purpose == SsoTransactionPurpose.Reauthentication ? (int)ReauthenticationMaxAge.TotalSeconds : 0; + + string authorizeUrl; + if ((SsoProviderType)config.SsoProviderType == SsoProviderType.Oidc) + { + var metadata = await _oidc.GetMetadataAsync(config.Authority, cancellationToken: cancellationToken); + if (metadata == null || !IsHttps(metadata.AuthorizationEndpoint)) + return SsoBeginResult.Of(SsoBrokerOutcome.ServiceUnavailable); + + var state = NewSecret(); + var nonce = NewSecret(); + var verifier = NewSecret(); + transaction.StateHash = Hash(state); + transaction.NonceHash = Hash(nonce); + transaction.EncryptedIdpCodeVerifier = _encryption.Encrypt(verifier); + + var query = new List> + { + new("response_type", "code"), + new("client_id", config.ClientId), + new("redirect_uri", OidcRedirectUri), + new("scope", "openid email profile"), + new("state", state), + new("nonce", nonce), + new("code_challenge", S256(verifier)), + new("code_challenge_method", "S256") + }; + + // A shared installation's operator signs in to the provider afresh too: its browser may still hold the last + // operator's provider session, which an account chooser would let the next one pick (plan section 12.5.2). + // The callback checks that the sign-in is fresh. + if (forceAuthn || request.SharedInstallation) + { + query.Add(new("prompt", forceAuthn && request.SharedInstallation ? "login select_account" : "login")); + query.Add(new("max_age", maxAge.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + + if (mapping?.RequestAcrValues?.Count > 0) + query.Add(new("acr_values", string.Join(" ", mapping.RequestAcrValues))); + if (!string.IsNullOrWhiteSpace(mapping?.RequestClaims)) + query.Add(new("claims", mapping.RequestClaims)); + + authorizeUrl = Append(metadata.AuthorizationEndpoint, query); + } + else + { + var relayState = SamlRelayPrefix + NewSecret(); + var requestId = "_" + Convert.ToHexString(RandomNumberGenerator.GetBytes(20)).ToLowerInvariant(); + transaction.StateHash = Hash(relayState); + transaction.SamlRequestId = requestId; + // SAML has no account chooser; on a shared installation the provider authenticates again instead. + authorizeUrl = BuildSamlRedirect(config, requestId, relayState, now, forceAuthn || request.SharedInstallation, request.DepartmentCode, + mapping?.RequestAuthnContextClassRefs); + if (authorizeUrl == null) + return SsoBeginResult.Of(SsoBrokerOutcome.ServiceUnavailable); + } + + await _transactions.InsertAsync(transaction, cancellationToken); + return new SsoBeginResult + { + Outcome = SsoBrokerOutcome.Succeeded, + AuthorizeUrl = authorizeUrl, + TransactionId = transaction.SsoLoginTransactionId, + ExpiresInSeconds = (int)Lifetime.TotalSeconds + }; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A brokered SSO sign-in could not be started."); + return SsoBeginResult.Of(SsoBrokerOutcome.ServiceUnavailable); + } + } + + /// + /// Each purpose is bound to what it serves: reauthentication, a mapping test and a Protected Data Grant step-up to the + /// signed-in session; a step-up to the session and a named operation, or to a password sign-in's login transaction. + /// + private static bool IsBindable(SsoBeginRequest request) + { + var session = !string.IsNullOrWhiteSpace(request.SessionId) && !string.IsNullOrWhiteSpace(request.UserId) && request.AuthenticationGeneration != null; + return request.Purpose switch + { + SsoTransactionPurpose.Login => true, + SsoTransactionPurpose.Reauthentication or SsoTransactionPurpose.MappingTest or SsoTransactionPurpose.AdpStepUp => session, + SsoTransactionPurpose.StepUp when !string.IsNullOrWhiteSpace(request.LoginTransactionId) => + !string.IsNullOrWhiteSpace(request.UserId) && request.Operation == SsoLoginTransaction.LoginOperation, + // Account factors are never managed through provider step-up (plan section 7.6 row 14). A shared session's unlock + // is bound to that session like any step-up. + SsoTransactionPurpose.StepUp => session && ((MfaStepUpOperations.IsKnown(request.Operation) && request.Operation != MfaStepUpOperations.AccountSecurity) || + request.Operation == SsoLoginTransaction.SharedUnlockOperation), + _ => false + }; + } + + /// + /// The mapping this round trip requests: always the tested one for a step-up the department accepts; the saved one + /// (tested or not) for the managing member's test; for a sign-in, the tested one when the department accepts provider + /// MFA for sign-in, so the provider's MFA can satisfy it with no Resgrid prompt. Null means no MFA is requested. + /// + private async Task MappingToRequestAsync(SsoBeginRequest request, DepartmentSsoConfig config, CancellationToken cancellationToken) + { + var mapping = FederatedMfaMapping.Parse(config.FederatedMfaMappingJson); + if (mapping == null || FederatedMfaMapping.Validate(mapping, (SsoProviderType)config.SsoProviderType) != null) + return null; + + switch (request.Purpose) + { + case SsoTransactionPurpose.MappingTest: + return mapping; + case SsoTransactionPurpose.StepUp: + case SsoTransactionPurpose.Login: + var scope = request.Purpose == SsoTransactionPurpose.Login || request.Operation is SsoLoginTransaction.LoginOperation or SsoLoginTransaction.SharedUnlockOperation + ? MfaMethodScope.Login + : MfaStepUpOperations.ScopeFor(request.Operation); + return FederatedMfaMapping.IsTested(config) && + await _policy.IsMethodAcceptedAsync(request.DepartmentId, scope, MfaEvidenceMethod.Federated, cancellationToken) + ? mapping + : null; + case SsoTransactionPurpose.AdpStepUp: + // Protected data follows the department's ADP switch, AllowFederatedMfaForAdp (plan section 10.1). + return FederatedMfaMapping.IsTested(config) && + await _policy.IsMethodAcceptedAsync(request.DepartmentId, MfaMethodScope.Adp, MfaEvidenceMethod.Federated, cancellationToken) + ? mapping + : null; + default: + return null; + } + } + + // ── IdP callbacks ───────────────────────────────────────────────────────────── + + public async Task CompleteOidcCallbackAsync(string state, string code, string error, string clientIpAddress, + CancellationToken cancellationToken = default) + { + var (transaction, refusal) = await OpenForCallbackAsync(state, SsoProviderType.Oidc, cancellationToken); + if (refusal != null) + return refusal; + + try + { + // The IdP declined (the user cancelled or was refused); the app learns only that. + if (!string.IsNullOrWhiteSpace(error)) + return await FailAsync(transaction, SsoBrokerOutcome.AccessDenied, "idp_error"); + if (string.IsNullOrWhiteSpace(code) || code.Length > 4096) + return await FailAsync(transaction, SsoBrokerOutcome.InvalidRequest, "missing_code"); + + var (config, department) = await ConfigForAsync(transaction, SsoProviderType.Oidc, cancellationToken); + if (config == null) + return await FailAsync(transaction, SsoBrokerOutcome.Unavailable, "config_changed"); + + var metadata = await _oidc.GetMetadataAsync(config.Authority, cancellationToken: cancellationToken); + if (metadata == null) + return await FailAsync(transaction, SsoBrokerOutcome.ServiceUnavailable, "idp_metadata_unavailable"); + + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcRedirectUri, + ["client_id"] = config.ClientId, + ["code_verifier"] = _encryption.Decrypt(transaction.EncryptedIdpCodeVerifier) + }; + if (!string.IsNullOrWhiteSpace(config.EncryptedClientSecret)) + form["client_secret"] = _encryption.DecryptForDepartment(config.EncryptedClientSecret, department.DepartmentId, department.Code); + + var exchanged = await _oidc.ExchangeCodeAsync(metadata.TokenEndpoint, form, cancellationToken); + if (string.IsNullOrWhiteSpace(exchanged?.IdToken)) + return await FailAsync(transaction, SsoBrokerOutcome.VerificationFailed, "code_exchange_failed"); + + var (principal, token) = await ValidateIdTokenAsync(exchanged.IdToken, config, metadata, cancellationToken); + if (principal == null || !NonceMatches(token, transaction.NonceHash) || !AuthorizedPartyMatches(token, config.ClientId)) + return await FailAsync(transaction, SsoBrokerOutcome.VerificationFailed, "id_token_invalid"); + + var authTime = AuthTime(token); + if (MustBeFresh(transaction) && !IsFresh(authTime)) + return await FailAsync(transaction, SsoBrokerOutcome.ReauthenticationNotFresh, "auth_time_not_fresh"); + + // Every id_token is accepted once, until it expires (plan section 7.7.2 item 8). + if (!await TryRecordIdTokenUseAsync(exchanged.IdToken, token.ValidTo, cancellationToken)) + return await FailAsync(transaction, SsoBrokerOutcome.VerificationFailed, "id_token_replayed"); + + return await FinishAsync(transaction, principal, config, department, authTime, OidcSignals(token), clientIpAddress, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A brokered OIDC callback failed; the sign-in was refused."); + return await FailAsync(transaction, SsoBrokerOutcome.ServiceUnavailable, "callback_error"); + } + } + + public bool IsBrokeredRelayState(string relayState) => + !string.IsNullOrWhiteSpace(relayState) && relayState.StartsWith(SamlRelayPrefix, StringComparison.Ordinal) && relayState.Length <= MaxSecretLength; + + public async Task CompleteSamlCallbackAsync(string relayState, string samlResponse, string clientIpAddress, + CancellationToken cancellationToken = default) + { + if (!IsBrokeredRelayState(relayState)) + return new SsoCallbackResult { Outcome = SsoBrokerOutcome.TransactionInvalid }; + + var (transaction, refusal) = await OpenForCallbackAsync(relayState, SsoProviderType.Saml2, cancellationToken); + if (refusal != null) + return refusal; + + try + { + var (config, department) = await ConfigForAsync(transaction, SsoProviderType.Saml2, cancellationToken); + if (config == null) + return await FailAsync(transaction, SsoBrokerOutcome.Unavailable, "config_changed"); + + // Only a response to this transaction's AuthnRequest is accepted; unsolicited responses stay on the legacy relay. + var assertion = await _departmentSso.ValidateBrokeredSamlResponseAsync(department.DepartmentId, samlResponse, department.Code, + transaction.SamlRequestId, cancellationToken); + if (assertion?.Principal == null) + return await FailAsync(transaction, SsoBrokerOutcome.VerificationFailed, "saml_response_invalid"); + + if (MustBeFresh(transaction) && !IsFresh(assertion.AuthenticatedAtUtc)) + return await FailAsync(transaction, SsoBrokerOutcome.ReauthenticationNotFresh, "authn_instant_not_fresh"); + + return await FinishAsync(transaction, assertion.Principal, config, department, assertion.AuthenticatedAtUtc, + new FederatedMfaSignals { AuthnContextClassRefs = assertion.AuthnContextClassRefs }, clientIpAddress, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A brokered SAML callback failed; the sign-in was refused."); + return await FailAsync(transaction, SsoBrokerOutcome.ServiceUnavailable, "callback_error"); + } + } + + // ── Redemption ──────────────────────────────────────────────────────────────── + + public async Task RedeemAsync(string transactionId, string code, string codeVerifier, UserSessionClientApplication client, + CancellationToken cancellationToken = default, params SsoTransactionPurpose[] purposes) + { + if (purposes == null || purposes.Length == 0) + purposes = new[] { SsoTransactionPurpose.Login, SsoTransactionPurpose.Reauthentication }; + + if (!IsEnabled) + return SsoRedemptionResult.Of(SsoBrokerOutcome.Unavailable); + if (string.IsNullOrWhiteSpace(transactionId) || transactionId.Length > 64 || string.IsNullOrWhiteSpace(code) || code.Length > MaxSecretLength || + !IsPkceVerifier(codeVerifier)) + return SsoRedemptionResult.Of(SsoBrokerOutcome.InvalidRequest); + + try + { + var transaction = await _transactions.GetAsync(transactionId, cancellationToken); + if (transaction == null || transaction.ClientApplication != (int)client || !purposes.Contains(transaction.TransactionPurpose)) + return SsoRedemptionResult.Of(SsoBrokerOutcome.TransactionInvalid); + + var now = _time.GetUtcNow().UtcDateTime; + switch (transaction.TransactionState) + { + case SsoLoginTransactionState.Redeemed: + return SsoRedemptionResult.Of(SsoBrokerOutcome.AlreadyUsed); + case SsoLoginTransactionState.Authenticated when transaction.CodeExpiresOnUtc <= now: + return SsoRedemptionResult.Of(SsoBrokerOutcome.Expired); + case SsoLoginTransactionState.Authenticated: + break; + default: + return SsoRedemptionResult.Of(SsoBrokerOutcome.TransactionInvalid); + } + + // PKCE: only the client that began the sign-in holds the verifier, so an intercepted code is useless. + if (!FixedTimeEquals(S256(codeVerifier), transaction.CodeChallenge)) + return SsoRedemptionResult.Of(SsoBrokerOutcome.TransactionInvalid); + + if (!await _transactions.TryRedeemAsync(transaction.SsoLoginTransactionId, Hash(code), now, cancellationToken)) + return SsoRedemptionResult.Of(SsoBrokerOutcome.TransactionInvalid); + + transaction.State = (int)SsoLoginTransactionState.Redeemed; + transaction.RedeemedOnUtc = now; + return SsoRedemptionResult.Of(SsoBrokerOutcome.Succeeded, transaction); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A brokered SSO code could not be redeemed; the sign-in was refused."); + return SsoRedemptionResult.Of(SsoBrokerOutcome.ServiceUnavailable); + } + } + + public async Task TryRecordIdTokenUseAsync(string idToken, DateTime expiresOnUtc, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(idToken)) + return false; + + var now = _time.GetUtcNow().UtcDateTime; + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("sso-id-token:" + idToken))); + var expires = DateTime.SpecifyKind(expiresOnUtc, DateTimeKind.Utc) + ClockSkew; + return await _replay.TryClaimAsync(key, BrokerReplayKind.IdToken, expires > now ? expires : now.Add(ClockSkew), now, cancellationToken); + } + + // ── Steps ───────────────────────────────────────────────────────────────────── + + /// + /// The pending transaction a callback's state belongs to. An unknown state has no trusted return target, so the + /// callback shows an error instead of redirecting anywhere. + /// + private async Task<(SsoLoginTransaction Transaction, SsoCallbackResult Refusal)> OpenForCallbackAsync(string state, SsoProviderType provider, + CancellationToken cancellationToken) + { + if (!IsEnabled) + return (null, new SsoCallbackResult { Outcome = SsoBrokerOutcome.Unavailable }); + if (string.IsNullOrWhiteSpace(state) || state.Length > MaxSecretLength) + return (null, new SsoCallbackResult { Outcome = SsoBrokerOutcome.TransactionInvalid }); + + SsoLoginTransaction transaction; + try + { + transaction = await _transactions.GetByStateHashAsync(Hash(state), cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Brokered SSO transaction lookup failed; the sign-in was refused."); + return (null, new SsoCallbackResult { Outcome = SsoBrokerOutcome.ServiceUnavailable }); + } + + if (transaction == null || transaction.ProviderType != (int)provider) + return (null, new SsoCallbackResult { Outcome = SsoBrokerOutcome.TransactionInvalid }); + + // The state belongs to us, so its registered return target is trusted for reporting the refusal. + if (transaction.TransactionState != SsoLoginTransactionState.Pending) + return (null, new SsoCallbackResult { Outcome = SsoBrokerOutcome.AlreadyUsed, RedirectUrl = ErrorRedirect(transaction, SsoBrokerOutcome.AlreadyUsed) }); + if (transaction.ExpiresOnUtc <= _time.GetUtcNow().UtcDateTime) + return (null, await FailAsync(transaction, SsoBrokerOutcome.Expired, "expired")); + + return (transaction, null); + } + + private async Task<(DepartmentSsoConfig Config, Department Department)> ConfigForAsync(SsoLoginTransaction transaction, SsoProviderType provider, + CancellationToken cancellationToken) + { + var config = await _departmentSso.GetSsoConfigForDepartmentAsync(transaction.DepartmentId, provider, cancellationToken); + if (config == null || !config.IsEnabled || !string.Equals(config.DepartmentSsoConfigId, transaction.DepartmentSsoConfigId, StringComparison.Ordinal)) + return (null, null); + + var department = await _departments.GetDepartmentByIdAsync(transaction.DepartmentId); + return department == null ? (null, null) : (config, department); + } + + /// + /// Resolves the Resgrid account and issues the one-time code. + /// + /// A login links or provisions the account exactly as the legacy exchange does, under the department's SSO and + /// IP rules; if the round trip asked for provider MFA and got it, the matched value rides along to redemption. + /// Reauthentication and a step-up only look the link up, and must find the account that began them. + /// A step-up and a mapping test need a provider sign-in after the transaction began, carrying a value the + /// unchanged mapping counts as MFA (plan section 7.8 acceptance rules). + /// + /// + private async Task FinishAsync(SsoLoginTransaction transaction, ClaimsPrincipal principal, DepartmentSsoConfig config, + Department department, DateTime? authenticatedAtUtc, FederatedMfaSignals signals, string clientIpAddress, CancellationToken cancellationToken) + { + var purpose = transaction.TransactionPurpose; + string matched = null; + if (transaction.FederatedMappingVersion != null && config.FederatedMfaMappingVersion == transaction.FederatedMappingVersion) + matched = FederatedMfaMapping.Parse(config.FederatedMfaMappingJson)?.Match(signals); + + if (purpose is SsoTransactionPurpose.StepUp or SsoTransactionPurpose.AdpStepUp or SsoTransactionPurpose.MappingTest) + { + if (!AuthenticatedSince(authenticatedAtUtc, transaction.CreatedOnUtc)) + return await FailAsync(transaction, SsoBrokerOutcome.ReauthenticationNotFresh, "authentication_not_fresh"); + if (matched == null) + return await FailAsync(transaction, SsoBrokerOutcome.FederatedNotSatisfied, + config.FederatedMfaMappingVersion == transaction.FederatedMappingVersion ? "mfa_not_asserted" : "mapping_changed"); + } + + string userId; + if (purpose is SsoTransactionPurpose.Reauthentication or SsoTransactionPurpose.StepUp or SsoTransactionPurpose.AdpStepUp) + { + userId = await _departmentSso.FindLinkedUserIdAsync(department.DepartmentId, principal, config, cancellationToken); + if (userId == null || !string.Equals(userId, transaction.ExpectedUserId, StringComparison.OrdinalIgnoreCase)) + return await FailAsync(transaction, SsoBrokerOutcome.IdentityMismatch, "identity_mismatch"); + } + else if (purpose == SsoTransactionPurpose.MappingTest) + { + // A test proves the mapping works at the provider, with whichever account the managing member signs in with; + // it authenticates nobody to Resgrid. + userId = transaction.ExpectedUserId; + } + else + { + var user = await _departmentSso.ProvisionOrLinkUserAsync(department.DepartmentId, principal, config, department.Code, cancellationToken); + if (user == null) + return await FailAsync(transaction, SsoBrokerOutcome.AccessDenied, "no_linked_user"); + + // MFA is completed through the login transaction when the code is redeemed; RequireSso and IP rules apply now. + var violation = await _departmentSso.EnforceSecurityPolicyAsync(department.DepartmentId, user.Id, clientIpAddress, + mfaCompleted: true, loginViaSso: true, cancellationToken); + if (!string.IsNullOrWhiteSpace(violation)) + return await FailAsync(transaction, SsoBrokerOutcome.AccessDenied, "policy_denied"); + + userId = user.Id; + } + + var now = _time.GetUtcNow().UtcDateTime; + var code = NewSecret(); + // Evidence is never dated after it was received: a provider clock slightly ahead reads as now. + var authenticatedOn = authenticatedAtUtc is { } at && at <= now ? at : now; + if (!await _transactions.TryAuthenticateAsync(transaction.SsoLoginTransactionId, userId, authenticatedOn, matched, Hash(code), now.Add(CodeLifetime), + now, cancellationToken)) + return new SsoCallbackResult { Outcome = SsoBrokerOutcome.AlreadyUsed, RedirectUrl = ErrorRedirect(transaction, SsoBrokerOutcome.AlreadyUsed) }; + + return new SsoCallbackResult + { + Outcome = SsoBrokerOutcome.Succeeded, + RedirectUrl = Redirect(transaction, "sso_code", code) + }; + } + + /// The provider authenticated the user after the transaction began (bounded skew), never in the future. + private bool AuthenticatedSince(DateTime? authenticatedAtUtc, DateTime createdOnUtc) => + authenticatedAtUtc is { } at && at >= createdOnUtc - ClockSkew && at <= _time.GetUtcNow().UtcDateTime + ClockSkew; + + /// OIDC MFA signals: amr and acrs may be a string or an array; acr is a string. + private static FederatedMfaSignals OidcSignals(JwtSecurityToken token) + { + IReadOnlyCollection Read(string name) + { + if (token == null || !token.Payload.TryGetValue(name, out var value) || value == null) + return Array.Empty(); + + return value switch + { + string single => new[] { single }, + IEnumerable many => many.Select(item => item?.ToString()).Where(item => !string.IsNullOrEmpty(item)).ToList(), + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Array } array => + array.EnumerateArray().Select(item => item.ToString()).Where(item => !string.IsNullOrEmpty(item)).ToList(), + _ => new[] { value.ToString() } + }; + } + + return new FederatedMfaSignals { Amr = Read("amr"), Acr = Read("acr"), Acrs = Read("acrs") }; + } + + private async Task FailAsync(SsoLoginTransaction transaction, SsoBrokerOutcome outcome, string failureCode) + { + try + { + await _transactions.TryFailAsync(transaction.SsoLoginTransactionId, failureCode); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A failed brokered SSO transaction could not be closed."); + } + + Logging.LogInfo($"Brokered SSO {SsoBrokerOutcomes.PurposeName(transaction.TransactionPurpose)} for department {transaction.DepartmentId} refused: {failureCode}."); + return new SsoCallbackResult { Outcome = outcome, RedirectUrl = ErrorRedirect(transaction, outcome) }; + } + + private async Task<(ClaimsPrincipal Principal, JwtSecurityToken Token)> ValidateIdTokenAsync(string idToken, DepartmentSsoConfig config, + OidcProviderMetadata metadata, CancellationToken cancellationToken) + { + var handler = new JwtSecurityTokenHandler(); + try + { + return Validate(handler, idToken, config, metadata); + } + catch (SecurityTokenSignatureKeyNotFoundException) + { + // The IdP may have rotated its keys since they were cached: refetch once and try again below. + } + catch (Exception ex) when (ex is SecurityTokenException || ex is ArgumentException) + { + Logging.LogDebug($"Brokered id_token rejected: {ex.GetType().Name}."); + return default; + } + + var refreshed = await _oidc.GetMetadataAsync(config.Authority, forceRefresh: true, cancellationToken); + if (refreshed == null) + return default; + + try + { + return Validate(handler, idToken, config, refreshed); + } + catch (Exception ex) when (ex is SecurityTokenException || ex is ArgumentException) + { + Logging.LogDebug($"Brokered id_token rejected after a key refresh: {ex.GetType().Name}."); + return default; + } + } + + private static (ClaimsPrincipal, JwtSecurityToken) Validate(JwtSecurityTokenHandler handler, string idToken, DepartmentSsoConfig config, + OidcProviderMetadata metadata) + { + var keys = new JsonWebKeySet(metadata.JwksJson).GetSigningKeys(); + var principal = handler.ValidateToken(idToken, + DepartmentSsoService.BuildOidcValidationParameters(config.ClientId, metadata.Issuer, keys), out var validated); + return validated is JwtSecurityToken jwt ? (principal, jwt) : default; + } + + private static bool NonceMatches(JwtSecurityToken token, byte[] expectedHash) => + token != null && expectedHash != null && !string.IsNullOrWhiteSpace(token.Payload.Nonce) && + CryptographicOperations.FixedTimeEquals(Hash(token.Payload.Nonce), expectedHash); + + /// An id_token for several audiences must name this client as its authorized party (OIDC Core 3.1.3.7). + private static bool AuthorizedPartyMatches(JwtSecurityToken token, string clientId) + { + var audiences = token.Audiences.ToList(); + var azp = token.Payload.Azp; + return audiences.Count <= 1 + ? azp == null || string.Equals(azp, clientId, StringComparison.Ordinal) + : string.Equals(azp, clientId, StringComparison.Ordinal); + } + + private static DateTime? AuthTime(JwtSecurityToken token) + { + if (token == null || !token.Payload.TryGetValue("auth_time", out var value)) + return null; + + try + { + return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)).UtcDateTime; + } + catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is OverflowException || ex is ArgumentOutOfRangeException) + { + return null; + } + } + + /// + /// Reauthentication must be recent, and so must any round trip from a shared installation: an older provider sign-in + /// there may be the last operator's session, still in the installation's browser (plan section 12.5.2). + /// + private static bool MustBeFresh(SsoLoginTransaction transaction) => + transaction.TransactionPurpose == SsoTransactionPurpose.Reauthentication || transaction.SharedInstallation; + + /// The IdP authenticated the user within the reauthentication window (and not in the future). + private bool IsFresh(DateTime? authenticatedAtUtc) + { + if (authenticatedAtUtc == null) + return false; + + var now = _time.GetUtcNow().UtcDateTime; + return authenticatedAtUtc.Value >= now - ReauthenticationMaxAge - ClockSkew && authenticatedAtUtc.Value <= now + ClockSkew; + } + + public string LegacySamlSignInUrl(DepartmentSsoConfig config, string departmentCode, string relayState, bool forceAuthn) + { + if (config == null || (SsoProviderType)config.SsoProviderType != SsoProviderType.Saml2 || !SupportsBrokered(config) || + string.IsNullOrWhiteSpace(relayState) || IsBrokeredRelayState(relayState)) + return null; + + var requestId = "_" + Convert.ToHexString(RandomNumberGenerator.GetBytes(20)).ToLowerInvariant(); + return BuildSamlRedirect(config, requestId, relayState, _time.GetUtcNow().UtcDateTime, forceAuthn, departmentCode); + } + + // ── SAML AuthnRequest (HTTP-Redirect binding) ───────────────────────────────── + + private string BuildSamlRedirect(DepartmentSsoConfig config, string requestId, string relayState, DateTime now, bool forceAuthn, string departmentCode, + IReadOnlyCollection requestedAuthnContexts = null) + { + var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Encoding = new UTF8Encoding(false) }; + using var xml = new StringWriter(); + using (var writer = XmlWriter.Create(xml, settings)) + { + const string protocol = "urn:oasis:names:tc:SAML:2.0:protocol"; + const string assertion = "urn:oasis:names:tc:SAML:2.0:assertion"; + writer.WriteStartElement("samlp", "AuthnRequest", protocol); + writer.WriteAttributeString("xmlns", "saml", null, assertion); + writer.WriteAttributeString("ID", requestId); + writer.WriteAttributeString("Version", "2.0"); + writer.WriteAttributeString("IssueInstant", XmlConvert.ToString(now, XmlDateTimeSerializationMode.Utc)); + writer.WriteAttributeString("Destination", config.IdpSsoUrl); + writer.WriteAttributeString("AssertionConsumerServiceURL", config.AssertionConsumerServiceUrl); + writer.WriteAttributeString("ProtocolBinding", "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); + if (forceAuthn) + writer.WriteAttributeString("ForceAuthn", "true"); + writer.WriteElementString("saml", "Issuer", assertion, config.EntityId); + + // Provider step-up: ask for the authentication context the department's mapping names (plan section 7.8). + if (requestedAuthnContexts?.Count > 0) + { + writer.WriteStartElement("samlp", "RequestedAuthnContext", protocol); + writer.WriteAttributeString("Comparison", "exact"); + foreach (var context in requestedAuthnContexts) + writer.WriteElementString("saml", "AuthnContextClassRef", assertion, context); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + } + + using var deflated = new MemoryStream(); + using (var deflate = new DeflateStream(deflated, CompressionLevel.Optimal, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(xml.ToString()); + deflate.Write(bytes, 0, bytes.Length); + } + + var query = $"SAMLRequest={Uri.EscapeDataString(Convert.ToBase64String(deflated.ToArray()))}&RelayState={Uri.EscapeDataString(relayState)}"; + + // Signed when the department configured an SP signing key: the redirect binding signs the exact query string. + if (!string.IsNullOrWhiteSpace(config.EncryptedSigningCertificate)) + { + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(_encryption.DecryptForDepartment(config.EncryptedSigningCertificate, config.DepartmentId, departmentCode)); + query += "&SigAlg=" + Uri.EscapeDataString(SignedXmlRsaSha256); + var signature = rsa.SignData(Encoding.UTF8.GetBytes(query), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + query += "&Signature=" + Uri.EscapeDataString(Convert.ToBase64String(signature)); + } + catch (Exception ex) when (ex is CryptographicException || ex is ArgumentException || ex is FormatException) + { + Logging.LogException(ex, "The SAML SP signing key could not be used; the AuthnRequest was not sent."); + return null; + } + } + + return config.IdpSsoUrl + (config.IdpSsoUrl.Contains('?') ? "&" : "?") + query; + } + + private const string SignedXmlRsaSha256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + private static string Redirect(SsoLoginTransaction transaction, string name, string value) + { + var url = new StringBuilder(transaction.ReturnTarget).Append('?').Append(name).Append('=').Append(Uri.EscapeDataString(value)); + if (!string.IsNullOrEmpty(transaction.ClientState)) + url.Append("&state=").Append(Uri.EscapeDataString(transaction.ClientState)); + return url.ToString(); + } + + private static string ErrorRedirect(SsoLoginTransaction transaction, SsoBrokerOutcome outcome) => + Redirect(transaction, "error", SsoBrokerOutcomes.ErrorCode(outcome) ?? "sso_failed"); + + private static string Append(string endpoint, IEnumerable> query) => + endpoint + (endpoint.Contains('?') ? "&" : "?") + + string.Join("&", query.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + + private static string NewSecret() => Base64Url(RandomNumberGenerator.GetBytes(32)); + + private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Hash(string value) => SHA256.HashData(Encoding.UTF8.GetBytes(value)); + + private static string S256(string verifier) => Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + + private static bool FixedTimeEquals(string a, string b) => + a != null && b != null && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b)); + + /// An S256 challenge is 32 bytes in base64url: 43 characters. + private static bool IsS256Challenge(string value) => value is { Length: 43 } && value.All(IsBase64UrlCharacter); + + /// RFC 7636: 43 to 128 characters from the unreserved set. + private static bool IsPkceVerifier(string value) => + value is { Length: >= 43 and <= 128 } && value.All(c => IsBase64UrlCharacter(c) || c == '.' || c == '~'); + + private static bool IsBase64UrlCharacter(char c) => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_'; + + private static bool IsHttps(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; + } +} diff --git a/Core/Resgrid.Services/SsoReturnTargetRegistry.cs b/Core/Resgrid.Services/SsoReturnTargetRegistry.cs new file mode 100644 index 000000000..40614c666 --- /dev/null +++ b/Core/Resgrid.Services/SsoReturnTargetRegistry.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Parses and validates (workbook section 7.3): the only places the + /// server sends a one-time sso_code. Matching is exact (scheme, host, port and path), except that an RFC 8252 + /// loopback entry accepts any port. An app's custom scheme may belong to only one app, so no app can receive another's + /// code. Any problem makes the whole registry not ready, which allows no target at all. + /// + public sealed class SsoReturnTargetRegistry : ISsoReturnTargetRegistry + { + private static readonly Dictionary ClientNames = new(StringComparer.OrdinalIgnoreCase) + { + ["web"] = UserSessionClientApplication.Web, + ["responder"] = UserSessionClientApplication.Responder, + ["unit"] = UserSessionClientApplication.Unit, + ["dispatch"] = UserSessionClientApplication.Dispatch, + ["command"] = UserSessionClientApplication.Command, + ["ic"] = UserSessionClientApplication.Command + }; + + private static readonly HashSet ForbiddenSchemes = new(StringComparer.OrdinalIgnoreCase) + { + "javascript", "data", "file", "vbscript", "blob", "about", "ftp", "ws", "wss" + }; + + private sealed record Target(string Scheme, string Host, int? Port, string Path); + + private readonly Dictionary> _targets = new(); + + public SsoReturnTargetRegistry() : this(SsoConfig.BrokeredReturnTargets) + { + } + + public SsoReturnTargetRegistry(string configuration) + { + var problems = Parse(configuration); + Problems = problems; + IsReady = problems.Count == 0 && _targets.Count > 0; + if (!IsReady) + _targets.Clear(); + } + + public bool IsReady { get; } + + public IReadOnlyList Problems { get; } + + public bool IsAllowed(UserSessionClientApplication client, string returnTarget) + { + if (!IsReady || string.IsNullOrWhiteSpace(returnTarget) || returnTarget.Length > 1024 || !_targets.TryGetValue(client, out var targets)) + return false; + + if (!Uri.TryCreate(returnTarget, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || + !string.IsNullOrEmpty(uri.UserInfo) || returnTarget.Contains('#') || returnTarget.Contains('?')) + return false; + + var path = uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped); + return targets.Any(target => + string.Equals(target.Scheme, uri.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(target.Host, uri.Host, StringComparison.OrdinalIgnoreCase) && + (target.Port == null || target.Port == uri.Port) && + string.Equals(target.Path, path, StringComparison.Ordinal)); + } + + private List Parse(string configuration) + { + var problems = new List(); + if (string.IsNullOrWhiteSpace(configuration)) + { + problems.Add("No SSO return targets are configured; brokered SSO is unavailable on this deployment."); + return problems; + } + + var schemeOwners = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in configuration.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var equals = entry.IndexOf('='); + if (equals <= 0 || !ClientNames.TryGetValue(entry[..equals].Trim(), out var client)) + { + problems.Add("A return-target entry is not in the form client=target,target with a known client."); + continue; + } + + foreach (var raw in entry[(equals + 1)..].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var target = ParseTarget(raw, out var problem); + if (target == null) + { + problems.Add($"Client '{client}' has a return target that is not allowed: {problem}"); + continue; + } + + // A custom scheme is an app's own; two apps sharing one could receive each other's codes. + if (!IsWebScheme(target.Scheme)) + { + if (schemeOwners.TryGetValue(target.Scheme, out var owner) && owner != client) + { + problems.Add($"The custom scheme '{target.Scheme}' is registered to more than one client."); + continue; + } + + schemeOwners[target.Scheme] = client; + } + + if (!_targets.TryGetValue(client, out var list)) + _targets[client] = list = new List(); + list.Add(target); + } + } + + return problems.Distinct().ToList(); + } + + private static Target ParseTarget(string raw, out string problem) + { + problem = null; + + // RFC 8252 section 7.3: a native app's loopback redirect may use any port. + foreach (var loopback in new[] { "http://127.0.0.1:*/", "http://[::1]:*/" }) + { + if (raw.StartsWith(loopback, StringComparison.OrdinalIgnoreCase)) + { + var loopbackPath = raw[(loopback.Length - 1)..]; + if (loopbackPath.Contains('?') || loopbackPath.Contains('#') || loopbackPath.Contains('*')) + { + problem = "a loopback target has a query, fragment or wildcard path."; + return null; + } + + var host = loopback.Contains("[::1]") ? "[::1]" : "127.0.0.1"; + var probe = new Uri($"http://{host}:1{loopbackPath}"); + return new Target("http", probe.Host, null, probe.GetComponents(UriComponents.Path, UriFormat.UriEscaped)); + } + } + + if (raw.Contains('*') || !Uri.TryCreate(raw, UriKind.Absolute, out var uri)) + { + problem = "it is not an absolute URI (only loopback targets may use a wildcard port)."; + return null; + } + + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host)) + { + problem = "it has a query, fragment, credentials or no host."; + return null; + } + + if (ForbiddenSchemes.Contains(uri.Scheme)) + { + problem = $"the '{uri.Scheme}' scheme is never a return target."; + return null; + } + + if (uri.Scheme == Uri.UriSchemeHttp && !uri.IsLoopback) + { + problem = "plain http is allowed only for a loopback host."; + return null; + } + + // Only a loopback entry has no fixed port; every other target matches its port exactly (-1 when absent). + return new Target(uri.Scheme, uri.Host, uri.Port, uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped)); + } + + private static bool IsWebScheme(string scheme) => + string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || string.Equals(scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Core/Resgrid.Services/TrainingService.cs b/Core/Resgrid.Services/TrainingService.cs index 11366a01f..f559c89c4 100644 --- a/Core/Resgrid.Services/TrainingService.cs +++ b/Core/Resgrid.Services/TrainingService.cs @@ -7,6 +7,7 @@ using Resgrid.Model; using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -19,10 +20,13 @@ public class TrainingService : ITrainingService private readonly ITrainingUserRepository _trainingUserRepository; private readonly ICommunicationService _communicationService; private readonly IDepartmentsService _departmentService; + private readonly Lazy _searchProjections; public TrainingService(ITrainingRepository trainingRepository, ITrainingAttachmentRepository trainingAttachmentRepository, - ITrainingUserRepository trainingUserRepository, ITrainingQuestionRepository trainingQuestionRepository, ICommunicationService communicationService, IDepartmentsService departmentService) + ITrainingUserRepository trainingUserRepository, ITrainingQuestionRepository trainingQuestionRepository, ICommunicationService communicationService, IDepartmentsService departmentService, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _trainingRepository = trainingRepository; _trainingAttachmentRepository = trainingAttachmentRepository; _trainingUserRepository = trainingUserRepository; @@ -86,6 +90,7 @@ public async Task> GetAllTrainingsForDepartmentAsync(int departme saved.Users = users; } + if (_searchProjections != null) await _searchProjections.Value.ProjectTrainingAsync(saved, cancellationToken); return saved; } @@ -151,7 +156,10 @@ public async Task GetTrainingAttachmentByIdAsync(int trainin public async Task DeleteTrainingAsync(int trainingId, CancellationToken cancellationToken = default(CancellationToken)) { var training = await GetTrainingByIdAsync(trainingId); - return await _trainingRepository.DeleteAsync(training, cancellationToken); + var deleted = await _trainingRepository.DeleteAsync(training, cancellationToken); + if (deleted && training != null && _searchProjections != null) + await _searchProjections.Value.RemoveAsync(training.DepartmentId, SearchEntityTypes.Training, training.TrainingId.ToString(), cancellationToken); + return deleted; } public async Task ResetUserAsync(int trainingId, string userId, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Core/Resgrid.Services/UserDefinedFieldsService.cs b/Core/Resgrid.Services/UserDefinedFieldsService.cs index 15449d8df..fbecc41dc 100644 --- a/Core/Resgrid.Services/UserDefinedFieldsService.cs +++ b/Core/Resgrid.Services/UserDefinedFieldsService.cs @@ -8,6 +8,7 @@ using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -19,14 +20,17 @@ public class UserDefinedFieldsService : IUserDefinedFieldsService private readonly IUdfFieldValueRepository _valueRepository; private readonly IUnitOfWork _unitOfWork; private readonly IProtectedWorkflowService _protectedWorkflows; + private readonly Lazy _searchProjections; public UserDefinedFieldsService( IUdfDefinitionRepository definitionRepository, IUdfFieldRepository fieldRepository, IUdfFieldValueRepository valueRepository, IUnitOfWork unitOfWork, - IProtectedWorkflowService protectedWorkflows = null) + IProtectedWorkflowService protectedWorkflows = null, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _protectedWorkflows = protectedWorkflows; _definitionRepository = definitionRepository; _fieldRepository = fieldRepository; @@ -297,9 +301,25 @@ await _valueRepository.DeleteFieldValuesByEntityAndDefinitionAsync( throw; } + // Custom field values are part of the owning entity's search text; the entity was saved before its values. + if (_searchProjections != null && SearchFamily(entityType) is string family) + await _searchProjections.Value.RefreshAsync(departmentId, family, entityId, cancellationToken); + return new Dictionary>(); } + private static string SearchFamily(int entityType) + { + switch ((UdfEntityType)entityType) + { + case UdfEntityType.Call: return SearchEntityTypes.Call; + case UdfEntityType.Unit: return SearchEntityTypes.Unit; + case UdfEntityType.Personnel: return SearchEntityTypes.Personnel; + case UdfEntityType.Contact: return SearchEntityTypes.Contact; + default: return null; + } + } + public async Task DeleteFieldFromDefinitionAsync(string fieldId, int departmentId, string userId, CancellationToken cancellationToken = default) { diff --git a/Core/Resgrid.Services/UserSessionService.cs b/Core/Resgrid.Services/UserSessionService.cs index d787adeaf..8b9266ae2 100644 --- a/Core/Resgrid.Services/UserSessionService.cs +++ b/Core/Resgrid.Services/UserSessionService.cs @@ -21,13 +21,19 @@ public class UserSessionService : IUserSessionService private readonly IDepartmentSsoService _departmentSsoService; private readonly IClientSessionMetadataParser _metadataParser; private readonly IIpLocationProvider _ipLocationProvider; + private readonly IPasskeyFeatureGates _gates; + private readonly ISystemAuditsService _audits; + private readonly ISessionEventPublisher _sessionEvents; + private readonly TimeProvider _time; public UserSessionService(IUserSessionsRepository sessionsRepository, IIdentityUserRepository identityUserRepository, IIdentityRepository identityRepository, IDepartmentsService departmentsService, IDepartmentSsoService departmentSsoService, IClientSessionMetadataParser metadataParser, - IIpLocationProvider ipLocationProvider) + IIpLocationProvider ipLocationProvider, IPasskeyFeatureGates gates, ISystemAuditsService audits, ISessionEventPublisher sessionEvents, + TimeProvider time) { + _sessionEvents = sessionEvents; _sessionsRepository = sessionsRepository; _identityUserRepository = identityUserRepository; _identityRepository = identityRepository; @@ -35,6 +41,9 @@ public UserSessionService(IUserSessionsRepository sessionsRepository, _departmentSsoService = departmentSsoService; _metadataParser = metadataParser; _ipLocationProvider = ipLocationProvider; + _gates = gates; + _audits = audits; + _time = time; } public async Task CreateSessionAsync(SessionIssueContext context, CancellationToken cancellationToken = default) @@ -44,9 +53,10 @@ public async Task CreateSessionAsync(SessionIssueContext context, C if (string.IsNullOrWhiteSpace(context.UserId)) throw new ArgumentException("A user is required to create a session.", nameof(context)); - var now = DateTime.UtcNow; + var now = _time.GetUtcNow().UtcDateTime; var concurrentSessionLimit = 0; var concurrencyGateOn = DateTime.MinValue; + DepartmentSecurityPolicy policy = null; if (context.DepartmentId.HasValue) { var member = await _departmentsService.GetDepartmentMemberAsync(context.UserId, @@ -54,20 +64,27 @@ public async Task CreateSessionAsync(SessionIssueContext context, C if (member == null || member.IsDeleted || member.IsDisabled == true) throw new SessionCreationDeniedException("membership_inactive"); - if (TryGetDepartmentPolicyGate(out var policyGate) && now >= policyGate) + // Read for every new session: whether the department requires shared mode is not behind the session-policy + // gate, and a failed read refuses the sign-in rather than issuing a personal session by default. + policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( + context.DepartmentId.Value, cancellationToken); + if (TryGetDepartmentPolicyGate(out var policyGate) && now >= policyGate && policy?.MaxConcurrentSessions > 0) { - var policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( - context.DepartmentId.Value, cancellationToken); - if (policy?.MaxConcurrentSessions > 0) - { - // Deliberately not checked here: counting now and inserting later lets two concurrent - // logins both see room and both insert. The limit is enforced by the insert itself below. - concurrentSessionLimit = policy.MaxConcurrentSessions; - concurrencyGateOn = policyGate; - } + // Deliberately not checked here: counting now and inserting later lets two concurrent + // logins both see room and both insert. The limit is enforced by the insert itself below. + concurrentSessionLimit = policy.MaxConcurrentSessions; + concurrencyGateOn = policyGate; } } + // Shared mode (plan section 10.5), by the department's requirement or the installation's request while the gate is + // on. Either way the session takes the department's idle lock and shift ceiling. + var sharedSource = SharedSessionRules.SourceFor(policy, context.ClientApplication, context.SharedModeRequested, _gates.SharedDeviceModeEnabled); + var shared = sharedSource != SharedModeSource.None; + var expiresOn = context.ExpiresOn > now ? context.ExpiresOn : now.AddHours(24); + if (shared && expiresOn > now.AddHours(SharedSessionRules.ShiftHours(policy))) + expiresOn = now.AddHours(SharedSessionRules.ShiftHours(policy)); + var metadata = _metadataParser.Parse(context.UserAgent, context.DeviceName, context.DeviceType, context.OperatingSystem, context.Browser, context.ApplicationVersion); var location = await ResolveLocationAsync(context.IpAddress, context.Country, context.Region, @@ -93,14 +110,20 @@ public async Task CreateSessionAsync(SessionIssueContext context, C WebCookieTicketKey = Limit(context.WebCookieTicketKey, 512), CreatedOn = now, LastActiveOn = now, - ExpiresOn = context.ExpiresOn > now ? context.ExpiresOn : now.AddHours(24), + ExpiresOn = expiresOn, FirstIpAddress = CanonicalIp(context.IpAddress), LastIpAddress = CanonicalIp(context.IpAddress), LastCountry = Limit(location?.Country, 128), LastRegion = Limit(location?.Region, 128), LastCity = Limit(location?.City, 128), UserAgent = Limit(context.UserAgent, Math.Max(128, SessionSecurityConfig.UserAgentMaximumLength)), - IsLegacyAdopted = context.IsLegacyAdopted + IsLegacyAdopted = context.IsLegacyAdopted, + LoginMfaMethod = context.LoginMfaMethod == null ? null : (int)context.LoginMfaMethod.Value, + LoginMfaFactorReference = Limit(context.LoginMfaFactorReference, 256), + SharedMode = shared, + SharedModeSource = (int)sharedSource, + SharedIdleLockMinutes = shared ? SharedSessionRules.IdleLockMinutes(policy) : null, + LastOperatorActivityOn = shared ? now : null }; if (concurrentSessionLimit > 0) @@ -157,6 +180,7 @@ public async Task ValidateAsync(SessionPrincipalContext return SessionValidationResult.Valid(canAdoptLegacy: true); } + var now = _time.GetUtcNow().UtcDateTime; var session = await _sessionsRepository.GetByIdAsync(context.SessionId); if (session == null) return SessionValidationResult.Invalid("session_not_found"); @@ -164,13 +188,14 @@ public async Task ValidateAsync(SessionPrincipalContext return SessionValidationResult.Invalid("session_user_mismatch"); if (session.State != (int)UserSessionState.Active) return SessionValidationResult.Invalid("session_revoked"); - if (session.ExpiresOn <= DateTime.UtcNow) - return SessionValidationResult.Invalid("session_expired"); + if (session.ExpiresOn <= now) + return SessionValidationResult.Invalid(session.SharedMode ? SharedSessionRules.ExpiredFailureCode : "session_expired"); if (session.AuthenticationGeneration != user.AuthenticationGeneration || (context.AuthenticationGeneration.HasValue && context.AuthenticationGeneration.Value != user.AuthenticationGeneration)) return SessionValidationResult.Invalid("authentication_generation_mismatch"); if (session.DepartmentId.HasValue && context.DepartmentId.HasValue && session.DepartmentId != context.DepartmentId) return SessionValidationResult.Invalid("session_department_mismatch"); + DepartmentSecurityPolicy policy = null; if (session.DepartmentId.HasValue) { var member = await _departmentsService.GetDepartmentMemberAsync(context.UserId, @@ -180,15 +205,122 @@ public async Task ValidateAsync(SessionPrincipalContext if (TryGetDepartmentPolicyGate(out var policyGate) && session.CreatedOn >= policyGate) { - var policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( + policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( session.DepartmentId.Value, cancellationToken); if (policy?.SessionTimeoutMinutes > 0 && - DepartmentSecurityPolicyDecisions.IdleExpired(policy.SessionTimeoutMinutes, session.LastActiveOn, DateTime.UtcNow)) + DepartmentSecurityPolicyDecisions.IdleExpired(policy.SessionTimeoutMinutes, session.LastActiveOn, now)) return SessionValidationResult.Invalid("session_idle_timeout"); } + else if (session.SharedMode) + { + policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync( + session.DepartmentId.Value, cancellationToken); + } + } + + return session.SharedMode ? await ValidateSharedAsync(session, policy, now) : SessionValidationResult.Valid(session); + } + + /// + /// The shared-session checks every validator applies (passkey plan section 12.5.3), so HTTP, the token endpoint, + /// SignalR and the broker all agree. The shift ceiling ends the session. A passed idle deadline locks it here, durably + /// and once, whether or not the client showed its lock screen. A locked session is invalid for everything except the + /// locked-session endpoints, which read it from the result. + /// + private async Task ValidateSharedAsync(UserSession session, DepartmentSecurityPolicy policy, DateTime now) + { + if (SharedSessionRules.ShiftEndsOn(session, policy) <= now) + return SessionValidationResult.Invalid(SharedSessionRules.ExpiredFailureCode); + + if (SharedSessionRules.IdleLockDue(session, policy, now)) + { + if (await _sessionsRepository.TryLockAsync(session.UserSessionId, session.LockVersion, (int)SharedSessionLockReason.Idle, now, + CancellationToken.None) == 1) + { + session.IsLocked = true; + session.LockVersion++; + session.LockedOnUtc = now; + session.LockReason = (int)SharedSessionLockReason.Idle; + await AuditIdleLockAsync(session); + await CloseConnectionsAsync(session.UserSessionId); + } + else + { + // Another request locked (or ended) it first. Whatever it did, this request is not let through on the stale row. + var current = await _sessionsRepository.GetByIdAsync(session.UserSessionId); + if (current == null || current.State != (int)UserSessionState.Active) + return SessionValidationResult.Invalid("session_revoked"); + session = current; + if (!session.IsLocked && SharedSessionRules.IdleLockDue(session, policy, now)) + return SessionValidationResult.Locked(session); + } + } + + return session.IsLocked ? SessionValidationResult.Locked(session) : SessionValidationResult.Valid(session); + } + + private async Task AuditIdleLockAsync(UserSession session) + { + try + { + await _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.SharedSessionLocked, + DepartmentId = session.DepartmentId, + UserId = session.UserId, + TargetUserId = session.UserId, + SessionId = SharedSessionAudit.SessionSuffix(session.UserSessionId), + Successful = true, + ServerName = Environment.MachineName, + Data = SharedSessionAudit.Describe("locked", session, "idle"), + LoggedOn = _time.GetUtcNow().UtcDateTime + }, CancellationToken.None); + } + catch (Exception ex) + { + // The lock stands without its audit row; failing the request would not undo it. + Resgrid.Framework.Logging.LogException(ex, "Shared session idle-lock audit failed."); } + } - return SessionValidationResult.Valid(session); + public async Task> GetUnusableSessionIdsAsync(IReadOnlyCollection sessionIds, CancellationToken cancellationToken = default) + { + var unusable = new HashSet(sessionIds ?? Array.Empty(), StringComparer.Ordinal); + if (unusable.Count == 0) + return unusable; + + var now = _time.GetUtcNow().UtcDateTime; + foreach (var session in await _sessionsRepository.GetStatesAsync(unusable.ToList(), cancellationToken)) + { + var idleLocksOn = (session.LastOperatorActivityOn ?? session.CreatedOn) + .AddMinutes(Math.Clamp(session.SharedIdleLockMinutes ?? SharedSessionRules.DefaultIdleLockMinutes, 1, SharedSessionRules.MaxIdleLockMinutes)); + var usable = session.State == (int)UserSessionState.Active && session.ExpiresOn > now && + !(session.SharedMode && (session.IsLocked || idleLocksOn <= now)); + if (usable) + unusable.Remove(session.UserSessionId); + } + + return unusable; + } + + public async Task RecordOperatorActivityAsync(UserSession session, CancellationToken cancellationToken = default) + { + if (session == null || !session.SharedMode || session.IsLocked) + return; + + var now = _time.GetUtcNow().UtcDateTime; + var writeBefore = now.AddSeconds(-Math.Max(1, PasskeyConfig.SharedActivityWriteIntervalSeconds)); + if ((session.LastOperatorActivityOn ?? session.CreatedOn) > writeBefore) + return; + + DepartmentSecurityPolicy policy = null; + if (session.DepartmentId.HasValue) + policy = await _departmentSsoService.GetSecurityPolicyForDepartmentAsync(session.DepartmentId.Value, cancellationToken); + var idleCutoff = now.AddMinutes(-SharedSessionRules.EffectiveIdleLockMinutes(session, policy)); + // The caller's copy follows the row, so a status read in the same request shows the moved deadline. + if (await _sessionsRepository.RecordOperatorActivityAsync(session.UserSessionId, now, writeBefore, idleCutoff, cancellationToken) > 0) + session.LastOperatorActivityOn = now; } public async Task AdoptLegacyAsync(LegacySessionContext context, CancellationToken cancellationToken = default) @@ -263,7 +395,9 @@ public async Task> GetActiveForUserAsync(strin LastRegion = session.LastRegion, LastCity = session.LastCity, UserAgent = session.UserAgent, - IsLegacyAdopted = session.IsLegacyAdopted + IsLegacyAdopted = session.IsLegacyAdopted, + SharedMode = session.SharedMode, + IsLocked = session.IsLocked }).ToList(); } @@ -272,9 +406,27 @@ public async Task RevokeSessionAsync(string actorUserId, strin { var now = DateTime.UtcNow; var count = await _sessionsRepository.RevokeAsync(targetUserId, sessionId, actorUserId, (int)reason, now, cancellationToken); + if (count > 0) + await CloseConnectionsAsync(sessionId); return new RevocationResult { RevokedSessionCount = count, RevokedOn = now }; } + /// + /// Asks every SignalR host to close this session's open connections now (slice 16). Best effort: each host's sweep + /// closes them within its interval anyway, and bulk revocations rely on the sweep alone. + /// + public async Task CloseConnectionsAsync(string sessionId) + { + try + { + await _sessionEvents.PublishAsync(sessionId, new SessionEventMessage { Name = SessionEvents.SessionClosed }, CancellationToken.None); + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex, "A session close event could not be sent; the connection sweep closes it instead."); + } + } + public async Task RevokeOtherSessionsAsync(string userId, string currentSessionId, UserSessionRevocationReason reason, CancellationToken cancellationToken = default) { diff --git a/Core/Resgrid.Services/WorkLogsService.cs b/Core/Resgrid.Services/WorkLogsService.cs index 16808ffff..059f09169 100644 --- a/Core/Resgrid.Services/WorkLogsService.cs +++ b/Core/Resgrid.Services/WorkLogsService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Search; using Resgrid.Model.Services; namespace Resgrid.Services @@ -25,12 +26,15 @@ public class WorkLogsService : IWorkLogsService // Lazy: the Records cutover guard (RMS plan section 4.1) is consulted only on a legacy write. private readonly Lazy _recordsCutoverService; + private readonly Lazy _searchProjections; public WorkLogsService(ILogsRepository logsRepository, ICallLogsRepository callLogsRepository, ILogUsersRepository logUsersRepository, ILogAttachmentRepository logAttachmentRepository, ILogUnitsRepository logUnitsRepository, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, ICallsService callsService, - Lazy protectedWriteService, Lazy recordsCutoverService) + Lazy protectedWriteService, Lazy recordsCutoverService, + Lazy searchProjections = null) { + _searchProjections = searchProjections; _recordsCutoverService = recordsCutoverService; _logsRepository = logsRepository; _callLogsRepository = callLogsRepository; @@ -139,6 +143,7 @@ public async Task GetCallLogByIdAsync(int callLogId) if (protectedWrite.Changed) savedLog = await _logsRepository.SaveOrUpdateAsync(savedLog, cancellationToken, true); + if (_searchProjections != null) await _searchProjections.Value.ProjectLogAsync(savedLog, cancellationToken); return savedLog; } @@ -221,7 +226,10 @@ public async Task> GetAllLogsByDepartmentDateRangeAsync(int department { await _recordsCutoverService.Value.EnsureLegacyWriteAllowedAsync(log.DepartmentId, "WorkLogsService.DeleteLogAsync"); - return await _logsRepository.DeleteAsync(log, cancellationToken); + var deleted = await _logsRepository.DeleteAsync(log, cancellationToken); + if (deleted && _searchProjections != null) + await _searchProjections.Value.RemoveAsync(log.DepartmentId, SearchEntityTypes.Log, log.LogId.ToString(), cancellationToken); + return deleted; } return false; diff --git a/Providers/Resgrid.Providers.Authentication/AuthenticationProviderModule.cs b/Providers/Resgrid.Providers.Authentication/AuthenticationProviderModule.cs new file mode 100644 index 000000000..d6a9bbfe4 --- /dev/null +++ b/Providers/Resgrid.Providers.Authentication/AuthenticationProviderModule.cs @@ -0,0 +1,18 @@ +using Autofac; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Authentication +{ + /// + /// Registers the WebAuthn protocol adapter and the brokered-SSO OIDC client. Load it in the hosts that run passkey + /// ceremonies and SSO (Web and API); it holds no secrets, only each client's relying-party configuration. + /// + public class AuthenticationProviderModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + } + } +} diff --git a/Providers/Resgrid.Providers.Authentication/Fido2PasskeyProvider.cs b/Providers/Resgrid.Providers.Authentication/Fido2PasskeyProvider.cs new file mode 100644 index 000000000..93cfece01 --- /dev/null +++ b/Providers/Resgrid.Providers.Authentication/Fido2PasskeyProvider.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Security; +using Resgrid.Model.Services; + +namespace Resgrid.Providers.Authentication +{ + /// + /// The WebAuthn adapter over Fido2 4.1.0 (passkey plan section 4; Phase 0 workbook section 3). One Fido2 instance per + /// client relying party, so a ceremony is only ever checked against the RP ID and exact origins of the client that + /// asked for it. Every ceremony requires user verification; registration asks for a discoverable credential with + /// attestation "none". The library is stateless: the caller supplies the single-use options and the bound credential. + /// + public sealed class Fido2PasskeyProvider : IPasskeyProvider + { + // Clock drift allowed on the client data timestamp; the challenge lifetime is what bounds a ceremony. + private const int TimestampDriftToleranceMs = 300000; + + private readonly Dictionary _servers = new(); + + public Fido2PasskeyProvider(IRelyingPartyRegistry registry) + { + if (!registry.Readiness.IsReady) + return; + + foreach (var client in Enum.GetValues()) + { + var party = registry.Get(client); + if (party == null) + continue; + + _servers[client] = new Fido2(new Fido2Configuration + { + RPID = party.RpId, + RPName = string.IsNullOrWhiteSpace(PasskeyConfig.RelyingPartyName) ? "Resgrid" : PasskeyConfig.RelyingPartyName, + Origins = new HashSet(party.Origins, StringComparer.Ordinal), + TimestampDriftTolerance = TimestampDriftToleranceMs + }); + } + } + + public bool IsAvailableFor(UserSessionClientApplication client) => _servers.ContainsKey(client); + + public string CreateRegistrationOptions(UserSessionClientApplication client, byte[] userHandle, string userName, string displayName, + IReadOnlyList excludeCredentialIds, bool preferRoaming) + { + var server = ServerFor(client); + var options = server.RequestNewCredential(new RequestNewCredentialParams + { + User = new Fido2User { Id = userHandle, Name = userName, DisplayName = displayName }, + ExcludeCredentials = (excludeCredentialIds ?? Array.Empty()).Select(id => new PublicKeyCredentialDescriptor(id)).ToList(), + AuthenticatorSelection = new AuthenticatorSelection + { + ResidentKey = ResidentKeyRequirement.Required, + UserVerification = UserVerificationRequirement.Required, + // A shared installation asks for a security key or phone (plan section 6.5); the response is still checked. + AuthenticatorAttachment = preferRoaming ? AuthenticatorAttachment.CrossPlatform : null + }, + AttestationPreference = AttestationConveyancePreference.None + }); + + if (preferRoaming) + options.Hints = new[] { PublicKeyCredentialHint.SecurityKey, PublicKeyCredentialHint.Hybrid }; + + return options.ToJson(); + } + + public async Task VerifyRegistrationAsync(UserSessionClientApplication client, string optionsJson, + string attestationResponseJson, CancellationToken cancellationToken = default) + { + if (!_servers.TryGetValue(client, out var server) || string.IsNullOrWhiteSpace(optionsJson) || string.IsNullOrWhiteSpace(attestationResponseJson)) + return PasskeyRegistrationVerification.Failed(); + + try + { + var options = CredentialCreateOptions.FromJson(optionsJson); + var response = JsonSerializer.Deserialize(attestationResponseJson); + if (response?.Response == null) + return PasskeyRegistrationVerification.Failed(); + + // Uniqueness is enforced by the (RP, credential id) key when the row is inserted, which is atomic across + // nodes; a check here could only race it. + var credential = await server.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = response, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = (_, _) => Task.FromResult(true) + }, cancellationToken); + + return new PasskeyRegistrationVerification + { + Succeeded = true, + CredentialId = credential.Id, + PublicKey = credential.PublicKey, + Algorithm = ReadCoseAlgorithm(credential.PublicKey), + UserHandle = options.User.Id, + SignCount = credential.SignCount, + IsBackupEligible = credential.IsBackupEligible, + IsBackedUp = credential.IsBackedUp, + Transports = (credential.Transports ?? Array.Empty()).Select(TransportName).Where(t => t != null).ToList(), + Aaguid = credential.AaGuid, + AttestationFormat = credential.AttestationFormat, + Attachment = ReadAttachment(attestationResponseJson) + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // The reason stays in the log (value-free); the client only learns that verification failed. + Framework.Logging.LogDebug($"Passkey registration verification failed for {client}: {ex.GetType().Name}: {ex.Message}"); + return PasskeyRegistrationVerification.Failed(); + } + } + + public string CreateAssertionOptions(UserSessionClientApplication client, IReadOnlyList allowCredentialIds) + { + var server = ServerFor(client); + var options = server.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = (allowCredentialIds ?? Array.Empty()).Select(id => new PublicKeyCredentialDescriptor(id)).ToList(), + UserVerification = UserVerificationRequirement.Required + }); + return options.ToJson(); + } + + public byte[] ReadCredentialId(string assertionResponseJson) + { + if (string.IsNullOrWhiteSpace(assertionResponseJson)) + return null; + + try + { + var response = JsonSerializer.Deserialize(assertionResponseJson); + return response?.RawId is { Length: > 0 } rawId ? rawId : null; + } + catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is ArgumentException || ex is NotSupportedException) + { + return null; + } + } + + public async Task VerifyAssertionAsync(UserSessionClientApplication client, string optionsJson, + string assertionResponseJson, PasskeyAssertionCredential credential, CancellationToken cancellationToken = default) + { + if (!_servers.TryGetValue(client, out var server) || credential?.PublicKey == null || credential.CredentialId == null + || string.IsNullOrWhiteSpace(optionsJson) || string.IsNullOrWhiteSpace(assertionResponseJson)) + return PasskeyAssertionVerification.Failed(); + + try + { + var options = AssertionOptions.FromJson(optionsJson); + var response = JsonSerializer.Deserialize(assertionResponseJson); + if (response?.Response == null || response.RawId == null || !response.RawId.AsSpan().SequenceEqual(credential.CredentialId)) + return PasskeyAssertionVerification.Failed(); + + // The library verifies against whatever key it is handed (Phase 0 spike): the caller has already loaded the + // credential by id for this user and client, and a returned user handle must be that credential's. + var result = await server.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = response, + OriginalOptions = options, + StoredPublicKey = credential.PublicKey, + StoredSignatureCounter = (uint)Math.Clamp(credential.SignCount, 0, uint.MaxValue), + IsUserHandleOwnerOfCredentialIdCallback = (owner, _) => Task.FromResult( + owner.CredentialId != null && owner.CredentialId.AsSpan().SequenceEqual(credential.CredentialId) && + owner.UserHandle != null && credential.UserHandle != null && owner.UserHandle.AsSpan().SequenceEqual(credential.UserHandle)) + }, cancellationToken); + + return new PasskeyAssertionVerification { Succeeded = true, SignCount = result.SignCount, IsBackedUp = result.IsBackedUp }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Framework.Logging.LogDebug($"Passkey assertion verification failed for {client}: {ex.GetType().Name}: {ex.Message}"); + return PasskeyAssertionVerification.Failed(); + } + } + + private Fido2 ServerFor(UserSessionClientApplication client) => + _servers.TryGetValue(client, out var server) + ? server + : throw new InvalidOperationException($"No relying party is configured for {client}."); + + private static string TransportName(AuthenticatorTransport transport) => transport switch + { + AuthenticatorTransport.Usb => "usb", + AuthenticatorTransport.Nfc => "nfc", + AuthenticatorTransport.Ble => "ble", + AuthenticatorTransport.SmartCard => "smart-card", + AuthenticatorTransport.Hybrid => "hybrid", + AuthenticatorTransport.Internal => "internal", + _ => null + }; + + /// The COSE "alg" (label 3) of a verified public key; null when it cannot be read. + internal static int? ReadCoseAlgorithm(byte[] coseKey) + { + if (coseKey == null || coseKey.Length == 0) + return null; + + try + { + var reader = new CborReader(coseKey, CborConformanceMode.Lax); + var entries = reader.ReadStartMap(); + for (var i = 0; entries == null || i < entries; i++) + { + if (reader.PeekState() == CborReaderState.EndMap) + break; + + if (reader.PeekState() is CborReaderState.UnsignedInteger or CborReaderState.NegativeInteger) + { + var label = reader.ReadInt64(); + // Checked: an alg outside the int range is unreadable, never truncated into a different algorithm. + if (label == 3 && reader.PeekState() is CborReaderState.UnsignedInteger or CborReaderState.NegativeInteger) + return checked((int)reader.ReadInt64()); + } + else + { + reader.SkipValue(); + } + + reader.SkipValue(); + } + } + catch (Exception ex) when (ex is CborContentException || ex is InvalidOperationException || ex is OverflowException) + { + } + + return null; + } + + /// The client-reported authenticatorAttachment of a registration response: a hint, never proof. + private static string ReadAttachment(string responseJson) + { + try + { + using var document = JsonDocument.Parse(responseJson); + if (document.RootElement.TryGetProperty("authenticatorAttachment", out var value) && value.ValueKind == JsonValueKind.String) + { + var attachment = value.GetString(); + if (attachment == "platform" || attachment == "cross-platform") + return attachment; + } + } + catch (JsonException) + { + } + + return null; + } + } +} diff --git a/Providers/Resgrid.Providers.Authentication/OidcProviderClient.cs b/Providers/Resgrid.Providers.Authentication/OidcProviderClient.cs new file mode 100644 index 000000000..b41272ccb --- /dev/null +++ b/Providers/Resgrid.Providers.Authentication/OidcProviderClient.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.Authentication +{ + /// + /// The HTTP side of brokered OIDC (passkey plan section 7.7.2): discovery and signing keys, cached per authority for + /// an hour, and the authorization-code exchange. Every URL must be https, redirects are not followed, responses are + /// size-limited, and a failure returns nothing rather than a guess. + /// + public sealed class OidcProviderClient : IOidcProviderClient + { + private static readonly TimeSpan CacheLifetime = TimeSpan.FromHours(1); + + // A key rotation may refetch, but never more often than this per authority. + private static readonly TimeSpan RefreshFloor = TimeSpan.FromSeconds(30); + + private static readonly HttpClient Http = new(new SocketsHttpHandler + { + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromMinutes(10) + }) + { + Timeout = TimeSpan.FromSeconds(10), + MaxResponseContentBufferSize = 1_048_576 + }; + + private static readonly ConcurrentDictionary Cache = + new(StringComparer.OrdinalIgnoreCase); + + public async Task GetMetadataAsync(string authority, bool forceRefresh = false, CancellationToken cancellationToken = default) + { + if (!IsHttps(authority)) + return null; + + var key = authority.TrimEnd('/'); + var now = DateTime.UtcNow; + if (Cache.TryGetValue(key, out var cached)) + { + var age = now - cached.FetchedOnUtc; + if (age < CacheLifetime && (!forceRefresh || age < RefreshFloor)) + return cached.Metadata; + } + + try + { + using var discovery = JsonDocument.Parse(await Http.GetStringAsync($"{key}/.well-known/openid-configuration", cancellationToken)); + var root = discovery.RootElement; + var issuer = Read(root, "issuer"); + var authorizationEndpoint = Read(root, "authorization_endpoint"); + var tokenEndpoint = Read(root, "token_endpoint"); + var jwksUri = Read(root, "jwks_uri"); + if (!IsHttps(issuer) || !IsHttps(authorizationEndpoint) || !IsHttps(tokenEndpoint) || !IsHttps(jwksUri)) + return null; + + var metadata = new OidcProviderMetadata + { + Issuer = issuer, + AuthorizationEndpoint = authorizationEndpoint, + TokenEndpoint = tokenEndpoint, + JwksJson = await Http.GetStringAsync(jwksUri, cancellationToken) + }; + Cache[key] = (metadata, now); + return metadata; + } + catch (Exception ex) when (ex is HttpRequestException || ex is JsonException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + Framework.Logging.LogDebug($"OIDC discovery failed for {key}: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + public async Task ExchangeCodeAsync(string tokenEndpoint, IReadOnlyDictionary form, + CancellationToken cancellationToken = default) + { + if (!IsHttps(tokenEndpoint) || form == null) + return new OidcCodeExchangeResult { Error = "invalid_request" }; + + try + { + using var content = new FormUrlEncodedContent(form); + using var response = await Http.PostAsync(tokenEndpoint, content, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + using var json = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + var idToken = Read(json.RootElement, "id_token"); + return response.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(idToken) + ? new OidcCodeExchangeResult { IdToken = idToken } + : new OidcCodeExchangeResult { Error = Read(json.RootElement, "error") ?? "invalid_grant" }; + } + catch (Exception ex) when (ex is HttpRequestException || ex is JsonException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + Framework.Logging.LogDebug($"OIDC code exchange failed: {ex.GetType().Name}: {ex.Message}"); + return new OidcCodeExchangeResult { Error = "temporarily_unavailable" }; + } + } + + private static string Read(JsonElement element, string name) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static bool IsHttps(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.UserInfo); + } +} diff --git a/Providers/Resgrid.Providers.Authentication/Resgrid.Providers.Authentication.csproj b/Providers/Resgrid.Providers.Authentication/Resgrid.Providers.Authentication.csproj new file mode 100644 index 000000000..fa4de459a --- /dev/null +++ b/Providers/Resgrid.Providers.Authentication/Resgrid.Providers.Authentication.csproj @@ -0,0 +1,22 @@ + + + net9.0 + Debug;Release;Docker + + + + + + + + + + + + + + + + + + diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs index 06bfc84f4..da27884cb 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundEventProvider.cs @@ -30,6 +30,7 @@ public class RabbitInboundEventProvider : IRabbitInboundEventProvider public Func ProcessIncidentCommandUpdated; public Func ProcessChatEvent; public Func ProcessChecklistEvent; + public Func ProcessSessionEvent; public async Task Start(string clientName, string queueName) { @@ -176,6 +177,9 @@ await _channel.QueueBindAsync(queue: queue.QueueName, case EventingTypes.ChecklistUpdated: if (ProcessChecklistEvent != null) await ProcessChecklistEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.ItemId); break; + case EventingTypes.SessionEvent: + if (ProcessSessionEvent != null) await ProcessSessionEvent.Invoke(eventingMessage.ItemId, eventingMessage.Payload); + break; case EventingTypes.ChatEvent: if (ProcessChatEvent != null) await ProcessChatEvent.Invoke(eventingMessage.DepartmentId, eventingMessage.Payload); @@ -242,6 +246,8 @@ public void RegisterForEvents(Func personnelStatusChanged, public void RegisterForChecklistEvents(Func checklistEvent) => ProcessChecklistEvent = checklistEvent; + public void RegisterForSessionEvents(Func sessionEvent) => ProcessSessionEvent = sessionEvent; + public void RegisterForChatEvents(Func chatEvent) { ProcessChatEvent = chatEvent; diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs index be39e97b3..afb78480f 100644 --- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs +++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs @@ -86,6 +86,12 @@ public async Task CallUpdated(CallUpdatedEvent message) Id = Guid.NewGuid(), Type = (int)EventingTypes.ChecklistUpdated, TimeStamp = DateTime.UtcNow, DepartmentId = departmentId, ItemId = completionId }.SerializeJson()); + /// An event for one session's realtime connections (passkey workbook section 7.4). + public Task SessionEvent(string sessionId, string payload) => SendMessage(Topics.EventingTopic, new EventingMessage + { + Id = Guid.NewGuid(), Type = (int)EventingTypes.SessionEvent, TimeStamp = DateTime.UtcNow, ItemId = sessionId, Payload = payload + }.SerializeJson()); + public async Task IncidentCommandUpdated(IncidentCommandUpdatedEvent message) { return await SendMessage(Topics.EventingTopic, new EventingMessage diff --git a/Providers/Resgrid.Providers.Bus/BusModule.cs b/Providers/Resgrid.Providers.Bus/BusModule.cs index ada6ceb05..59424e9ea 100644 --- a/Providers/Resgrid.Providers.Bus/BusModule.cs +++ b/Providers/Resgrid.Providers.Bus/BusModule.cs @@ -22,6 +22,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } } diff --git a/Providers/Resgrid.Providers.Bus/SessionEventPublisher.cs b/Providers/Resgrid.Providers.Bus/SessionEventPublisher.cs new file mode 100644 index 000000000..a90011187 --- /dev/null +++ b/Providers/Resgrid.Providers.Bus/SessionEventPublisher.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Framework; +using Resgrid.Model.Security; +using Resgrid.Providers.Bus.Rabbit; + +namespace Resgrid.Providers.Bus +{ + /// + /// Sends a session event over the eventing topic to the Eventing hosts, which forward it to that session's SignalR group + /// (passkey workbook section 7.4). Best effort: clients also poll, so a lost event only delays them. + /// + public sealed class SessionEventPublisher : ISessionEventPublisher + { + public async Task PublishAsync(string sessionId, SessionEventMessage message, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sessionId) || message == null || !SessionEvents.IsKnown(message.Name)) + return; + + try + { + if (!await new RabbitTopicProvider().SessionEvent(sessionId, JsonConvert.SerializeObject(message))) + Logging.LogError($"A {message.Name} session event could not be sent; the client falls back to polling."); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "A session event could not be sent; the client falls back to polling."); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0243_AddMfaFactorState.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0243_AddMfaFactorState.cs new file mode 100644 index 000000000..3f1d2b0a9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0243_AddMfaFactorState.cs @@ -0,0 +1,39 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1 (TOTP and recovery hardening): one-time TOTP time steps and hashed, single-use recovery + /// codes. Neither table holds a TOTP seed or a plaintext recovery code. + /// + [Migration(243)] + public class M0243_AddMfaFactorState : Migration + { + public override void Up() + { + if (!Schema.Table("UserTotpStates").Exists()) + Create.Table("UserTotpStates") + .WithColumn("UserId").AsString(128).PrimaryKey().NotNullable() + .WithColumn("LastAcceptedTimeStep").AsInt64().NotNullable() + .WithColumn("LastAcceptedOnUtc").AsDateTime().NotNullable() + .WithColumn("EnrolledOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("UserRecoveryCodes").Exists()) + Create.Table("UserRecoveryCodes") + .WithColumn("UserRecoveryCodeId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("CodeHash").AsBinary(32).NotNullable() + .WithColumn("HashVersion").AsInt32().NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("UsedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("UserRecoveryCodes").Index("UX_UserRecoveryCodes_UserHash").Exists()) + Create.Index("UX_UserRecoveryCodes_UserHash").OnTable("UserRecoveryCodes") + .OnColumn("UserId").Ascending() + .OnColumn("CodeHash").Ascending() + .WithOptions().Unique(); + } + + public override void Down() => throw new System.NotSupportedException("MFA factor state is security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0244_AddMfaEvidenceAndChallenges.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0244_AddMfaEvidenceAndChallenges.cs new file mode 100644 index 000000000..eb92173aa --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0244_AddMfaEvidenceAndChallenges.cs @@ -0,0 +1,76 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 2: single-use WebAuthn challenges and server-side MFA evidence per session. Neither + /// table holds a password, TOTP code, recovery code, assertion or token. + /// + [Migration(244)] + public class M0244_AddMfaEvidenceAndChallenges : Migration + { + public override void Up() + { + if (!Schema.Table("AuthenticationChallenges").Exists()) + Create.Table("AuthenticationChallenges") + .WithColumn("AuthenticationChallengeId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("Purpose").AsInt32().NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("RpId").AsString(253).NotNullable() + .WithColumn("ParentKind").AsInt32().NotNullable() + .WithColumn("ParentId").AsString(160).NotNullable() + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable() + .WithColumn("LockVersion").AsInt64().Nullable() + .WithColumn("OptionsJson").AsString(int.MaxValue).NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MaxAttempts").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ConsumedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("AuthenticationChallenges").Index("IX_AuthenticationChallenges_UserState").Exists()) + Create.Index("IX_AuthenticationChallenges_UserState").OnTable("AuthenticationChallenges") + .OnColumn("UserId").Ascending() + .OnColumn("State").Ascending(); + + if (!Schema.Table("AuthenticationChallenges").Index("IX_AuthenticationChallenges_Expires").Exists()) + Create.Index("IX_AuthenticationChallenges_Expires").OnTable("AuthenticationChallenges") + .OnColumn("ExpiresOnUtc").Ascending(); + + if (!Schema.Table("UserSessionMfaEvidence").Exists()) + Create.Table("UserSessionMfaEvidence") + .WithColumn("MfaEvidenceId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("SessionKey").AsString(160).NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("Method").AsInt32().NotNullable() + .WithColumn("Purpose").AsInt32().NotNullable() + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("VerifiedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable() + .WithColumn("FactorReference").AsString(256).Nullable() + .WithColumn("RevokedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("UserSessionMfaEvidence").Index("IX_UserSessionMfaEvidence_Session").Exists()) + Create.Index("IX_UserSessionMfaEvidence_Session").OnTable("UserSessionMfaEvidence") + .OnColumn("SessionKey").Ascending() + .OnColumn("Kind").Ascending() + .OnColumn("VerifiedOnUtc").Descending(); + + if (!Schema.Table("UserSessionMfaEvidence").Index("IX_UserSessionMfaEvidence_User").Exists()) + Create.Index("IX_UserSessionMfaEvidence_User").OnTable("UserSessionMfaEvidence") + .OnColumn("UserId").Ascending(); + + if (!Schema.Table("UserSessionMfaEvidence").Index("IX_UserSessionMfaEvidence_Expires").Exists()) + Create.Index("IX_UserSessionMfaEvidence_Expires").OnTable("UserSessionMfaEvidence") + .OnColumn("ExpiresOnUtc").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("MFA evidence and challenges are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0245_AddBrokerReplayKeys.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0245_AddBrokerReplayKeys.cs new file mode 100644 index 000000000..bb545ef9a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0245_AddBrokerReplayKeys.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 3: the shared, single-use record of Protected Data Broker request ids and session + /// assertion ids, replacing the broker's per-process replay cache. Keys are SHA-256 digests; no request content, + /// token, or user identifier is stored. + /// + [Migration(245)] + public class M0245_AddBrokerReplayKeys : Migration + { + public override void Up() + { + if (!Schema.Table("BrokerReplayKeys").Exists()) + Create.Table("BrokerReplayKeys") + .WithColumn("ReplayKey").AsAnsiString(64).PrimaryKey().NotNullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable(); + + if (!Schema.Table("BrokerReplayKeys").Index("IX_BrokerReplayKeys_Expires").Exists()) + Create.Index("IX_BrokerReplayKeys_Expires").OnTable("BrokerReplayKeys") + .OnColumn("ExpiresOnUtc").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Replay records are security state; they expire on their own."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0246_AddUserMfaPreferences.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0246_AddUserMfaPreferences.cs new file mode 100644 index 000000000..664cb5245 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0246_AddUserMfaPreferences.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 5: each user's last successful MFA method, shown as the default choice on any + /// installation (plan section 7.5 rule 5). Non-secret account metadata. + /// + [Migration(246)] + public class M0246_AddUserMfaPreferences : Migration + { + public override void Up() + { + if (!Schema.Table("UserMfaPreferences").Exists()) + Create.Table("UserMfaPreferences") + .WithColumn("UserId").AsString(128).PrimaryKey().NotNullable() + .WithColumn("PreferredMethod").AsInt32().NotNullable() + .WithColumn("UpdatedOnUtc").AsDateTime().NotNullable(); + } + + public override void Down() => Delete.Table("UserMfaPreferences"); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0247_AddSecurityPolicyMfaSwitches.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0247_AddSecurityPolicyMfaSwitches.cs new file mode 100644 index 000000000..d7b94edae --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0247_AddSecurityPolicyMfaSwitches.cs @@ -0,0 +1,41 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 6 (section 10.1): which second factors a department accepts, next to RequireMfa and + /// RequireSso, and the MFA policy version the server advances whenever the sign-in method rules change. Existing rows + /// take the defaults, which is also how a department with no row behaves. + /// + [Migration(247)] + public class M0247_AddSecurityPolicyMfaSwitches : Migration + { + public override void Up() + { + void AddFlag(string column, bool defaultValue) + { + if (!Schema.Table("DepartmentSecurityPolicies").Column(column).Exists()) + Alter.Table("DepartmentSecurityPolicies").AddColumn(column).AsBoolean().NotNullable().WithDefaultValue(defaultValue); + } + + AddFlag("AllowPasskeysForLoginMfa", true); + AddFlag("AllowPasskeysForAdp", true); + AddFlag("AllowFederatedMfaForLoginMfa", false); + AddFlag("AllowFederatedMfaForAdp", false); + AddFlag("AllowResponderApproval", true); + AddFlag("AcceptRecentLoginMfaForAdp", true); + AddFlag("AcceptRecentUnlockMfaForAdp", true); + + if (!Schema.Table("DepartmentSecurityPolicies").Column("MfaPolicyVersion").Exists()) + Alter.Table("DepartmentSecurityPolicies").AddColumn("MfaPolicyVersion").AsInt64().NotNullable().WithDefaultValue(0L); + } + + public override void Down() + { + foreach (var column in new[] { "AllowPasskeysForLoginMfa", "AllowPasskeysForAdp", "AllowFederatedMfaForLoginMfa", "AllowFederatedMfaForAdp", + "AllowResponderApproval", "AcceptRecentLoginMfaForAdp", "AcceptRecentUnlockMfaForAdp", "MfaPolicyVersion" }) + if (Schema.Table("DepartmentSecurityPolicies").Column(column).Exists()) + Delete.Column(column).FromTable("DepartmentSecurityPolicies"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0248_AddUserPasskeys.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0248_AddUserPasskeys.cs new file mode 100644 index 000000000..37142686d --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0248_AddUserPasskeys.cs @@ -0,0 +1,63 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 7: registered passkeys (plan section 5.1). Public keys and protocol metadata only; no + /// private key, biometric data, assertion or token. A credential id is unique within its relying party, so concurrent + /// registration can never attach one credential to two users. + /// + [Migration(248)] + public class M0248_AddUserPasskeys : Migration + { + public override void Up() + { + if (!Schema.Table("UserPasskeys").Exists()) + Create.Table("UserPasskeys") + .WithColumn("UserPasskeyId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("RpId").AsString(253).NotNullable() + .WithColumn("CredentialId").AsBinary(1023).NotNullable() + .WithColumn("CredentialIdHash").AsBinary(32).NotNullable() + .WithColumn("PublicKey").AsBinary(int.MaxValue).NotNullable() + .WithColumn("Algorithm").AsInt32().Nullable() + .WithColumn("UserHandle").AsBinary(64).NotNullable() + .WithColumn("SignCount").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("IsBackupEligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("IsBackedUp").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("Transports").AsString(128).Nullable() + .WithColumn("Aaguid").AsString(36).Nullable() + .WithColumn("AttestationFormat").AsString(32).Nullable() + .WithColumn("DisplayName").AsString(100).NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("RegistrationPlatform").AsString(128).Nullable() + .WithColumn("RegistrationInstallation").AsString(256).Nullable() + .WithColumn("RegistrationUserAgentFamily").AsString(128).Nullable() + .WithColumn("RegistrationAttachment").AsString(16).Nullable() + .WithColumn("RegisteredInSharedMode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("LastUsedOnUtc").AsDateTime().Nullable() + .WithColumn("LastUsedClientApplication").AsInt32().Nullable() + .WithColumn("LastUsedInstallation").AsString(256).Nullable() + .WithColumn("LastUsedInSharedMode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ApprovalEnabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("RevokedOnUtc").AsDateTime().Nullable() + .WithColumn("RevocationReason").AsInt32().Nullable() + .WithColumn("RevokedByUserId").AsString(128).Nullable() + .WithColumn("StateVersion").AsInt64().NotNullable().WithDefaultValue(1); + + if (!Schema.Table("UserPasskeys").Index("UX_UserPasskeys_RpCredential").Exists()) + Create.Index("UX_UserPasskeys_RpCredential").OnTable("UserPasskeys") + .OnColumn("RpId").Ascending() + .OnColumn("CredentialIdHash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("UserPasskeys").Index("IX_UserPasskeys_UserClient").Exists()) + Create.Index("IX_UserPasskeys_UserClient").OnTable("UserPasskeys") + .OnColumn("UserId").Ascending() + .OnColumn("ClientApplication").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Registered passkeys are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0249_AddMfaLoginTransactions.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0249_AddMfaLoginTransactions.cs new file mode 100644 index 000000000..8f3d64da3 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0249_AddMfaLoginTransactions.cs @@ -0,0 +1,65 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 8: restricted login MFA transactions (plan section 5.2), and the second factor on each session. Only SHA-256 hashes of the + /// transaction secret and the one-use completion code are stored; no password, code, assertion or token. + /// + [Migration(249)] + public class M0249_AddMfaLoginTransactions : Migration + { + public override void Up() + { + if (!Schema.Table("MfaLoginTransactions").Exists()) + Create.Table("MfaLoginTransactions") + .WithColumn("MfaLoginTransactionId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("SecretHash").AsBinary(32).NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("ClientId").AsString(128).Nullable() + .WithColumn("FirstFactorMethod").AsInt32().NotNullable() + .WithColumn("FirstFactorVerifiedOnUtc").AsDateTime().NotNullable() + .WithColumn("DepartmentSsoConfigId").AsString(128).Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable() + .WithColumn("MfaPolicyVersion").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("Scopes").AsString(512).Nullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MaxAttempts").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CompletionMethod").AsInt32().Nullable() + .WithColumn("CompletionFactorReference").AsString(256).Nullable() + .WithColumn("CompletionVerifiedOnUtc").AsDateTime().Nullable() + .WithColumn("IsRecovery").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CompletionCodeHash").AsBinary(32).Nullable() + .WithColumn("CompletionExpiresOnUtc").AsDateTime().Nullable() + .WithColumn("RedeemedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("MfaLoginTransactions").Index("UX_MfaLoginTransactions_SecretHash").Exists()) + Create.Index("UX_MfaLoginTransactions_SecretHash").OnTable("MfaLoginTransactions") + .OnColumn("SecretHash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("MfaLoginTransactions").Index("IX_MfaLoginTransactions_User").Exists()) + Create.Index("IX_MfaLoginTransactions_User").OnTable("MfaLoginTransactions") + .OnColumn("UserId").Ascending(); + + if (!Schema.Table("MfaLoginTransactions").Index("IX_MfaLoginTransactions_Expires").Exists()) + Create.Index("IX_MfaLoginTransactions_Expires").OnTable("MfaLoginTransactions") + .OnColumn("ExpiresOnUtc").Ascending(); + + // The second factor a session's sign-in verified, apart from its first-factor AuthenticationMethod (plan + // section 5.3), so removing a passkey can end the sessions that signed in with it. + if (!Schema.Table("UserSessions").Column("LoginMfaMethod").Exists()) + Alter.Table("UserSessions").AddColumn("LoginMfaMethod").AsInt32().Nullable(); + + if (!Schema.Table("UserSessions").Column("LoginMfaFactorReference").Exists()) + Alter.Table("UserSessions").AddColumn("LoginMfaFactorReference").AsString(256).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Login transactions are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0250_AddBrokeredSso.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0250_AddBrokeredSso.cs new file mode 100644 index 000000000..38c4409c6 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0250_AddBrokeredSso.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 9: server-brokered SSO transactions (plan section 7.7.2) and the SAML IdP SSO URL. Only + /// hashes of the IdP state, nonce and one-time code are stored, with the IdP PKCE verifier encrypted; no IdP token, + /// assertion or password. + /// + [Migration(250)] + public class M0250_AddBrokeredSso : Migration + { + public override void Up() + { + if (!Schema.Table("SsoLoginTransactions").Exists()) + Create.Table("SsoLoginTransactions") + .WithColumn("SsoLoginTransactionId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("StateHash").AsBinary(32).NotNullable() + .WithColumn("Purpose").AsInt32().NotNullable() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("DepartmentSsoConfigId").AsString(128).NotNullable() + .WithColumn("ProviderType").AsInt32().NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("Platform").AsString(32).Nullable() + .WithColumn("ReturnTarget").AsString(1024).NotNullable() + .WithColumn("ClientState").AsString(512).Nullable() + .WithColumn("CodeChallenge").AsString(128).NotNullable() + .WithColumn("NonceHash").AsBinary(32).Nullable() + .WithColumn("EncryptedIdpCodeVerifier").AsString(1024).Nullable() + .WithColumn("SamlRequestId").AsString(128).Nullable() + .WithColumn("SessionId").AsString(128).Nullable() + .WithColumn("ExpectedUserId").AsString(128).Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().Nullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("UserId").AsString(128).Nullable() + .WithColumn("AuthenticatedOnUtc").AsDateTime().Nullable() + .WithColumn("CodeHash").AsBinary(32).Nullable() + .WithColumn("CodeExpiresOnUtc").AsDateTime().Nullable() + .WithColumn("RedeemedOnUtc").AsDateTime().Nullable() + .WithColumn("FailureCode").AsString(64).Nullable(); + + if (!Schema.Table("SsoLoginTransactions").Index("UX_SsoLoginTransactions_StateHash").Exists()) + Create.Index("UX_SsoLoginTransactions_StateHash").OnTable("SsoLoginTransactions") + .OnColumn("StateHash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("SsoLoginTransactions").Index("IX_SsoLoginTransactions_Expires").Exists()) + Create.Index("IX_SsoLoginTransactions_Expires").OnTable("SsoLoginTransactions") + .OnColumn("ExpiresOnUtc").Ascending(); + + // The IdP's SAML single sign-on URL for SP-initiated (brokered) AuthnRequests. + if (!Schema.Table("DepartmentSsoConfigs").Column("IdpSsoUrl").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("IdpSsoUrl").AsString(1024).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Brokered SSO transactions are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0251_AddFederatedMfaMapping.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0251_AddFederatedMfaMapping.cs new file mode 100644 index 000000000..2d1259a82 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0251_AddFederatedMfaMapping.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 10: provider step-up (federated MFA). The department's mapping and its test result, and + /// the brokered transaction's step-up binding. No IdP token, assertion or secret. + /// + [Migration(251)] + public class M0251_AddFederatedMfaMapping : Migration + { + public override void Up() + { + // Provider step-up mapping and its test-before-enable state (plan section 7.8). Existing rows have no mapping. + if (!Schema.Table("DepartmentSsoConfigs").Column("FederatedMfaMappingJson").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("FederatedMfaMappingJson").AsString(int.MaxValue).Nullable(); + + if (!Schema.Table("DepartmentSsoConfigs").Column("FederatedMfaMappingVersion").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("FederatedMfaMappingVersion").AsInt64().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("DepartmentSsoConfigs").Column("FederatedMfaTestedVersion").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("FederatedMfaTestedVersion").AsInt64().Nullable(); + + if (!Schema.Table("DepartmentSsoConfigs").Column("FederatedMfaTestedOnUtc").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("FederatedMfaTestedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("DepartmentSsoConfigs").Column("FederatedMfaTestedByUserId").Exists()) + Alter.Table("DepartmentSsoConfigs").AddColumn("FederatedMfaTestedByUserId").AsString(128).Nullable(); + + // Brokered step-up binding and the value the mapping counted as MFA ("kind:value"; a value may be 256 characters). + if (!Schema.Table("SsoLoginTransactions").Column("Operation").Exists()) + Alter.Table("SsoLoginTransactions").AddColumn("Operation").AsString(64).Nullable(); + + if (!Schema.Table("SsoLoginTransactions").Column("LoginTransactionId").Exists()) + Alter.Table("SsoLoginTransactions").AddColumn("LoginTransactionId").AsString(36).Nullable(); + + if (!Schema.Table("SsoLoginTransactions").Column("FederatedMappingVersion").Exists()) + Alter.Table("SsoLoginTransactions").AddColumn("FederatedMappingVersion").AsInt64().Nullable(); + + if (!Schema.Table("SsoLoginTransactions").Column("FederatedMfaValue").Exists()) + Alter.Table("SsoLoginTransactions").AddColumn("FederatedMfaValue").AsString(512).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Provider step-up state is security configuration; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0252_AddMfaApprovalRequests.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0252_AddMfaApprovalRequests.cs new file mode 100644 index 000000000..0ff33033a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0252_AddMfaApprovalRequests.cs @@ -0,0 +1,55 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 11: Responder approval requests (plan section 5.6). The match number is stored only as a + /// hash bound to its request; no assertion, token or push payload is kept. + /// + [Migration(252)] + public class M0252_AddMfaApprovalRequests : Migration + { + public override void Up() + { + if (!Schema.Table("MfaApprovalRequests").Exists()) + Create.Table("MfaApprovalRequests") + .WithColumn("MfaApprovalRequestId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("RequesterKind").AsInt32().NotNullable() + .WithColumn("RequesterId").AsString(128).NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("InstallationLabel").AsString(256).Nullable() + .WithColumn("SharedMode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("Purpose").AsInt32().NotNullable() + .WithColumn("Operation").AsString(64).Nullable() + .WithColumn("LockVersion").AsInt64().Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable() + .WithColumn("MatchNumberHash").AsBinary(32).NotNullable() + .WithColumn("OriginRegion").AsString(256).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Version").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MaxAttempts").AsInt32().NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("DecidedOnUtc").AsDateTime().Nullable() + .WithColumn("ConsumedOnUtc").AsDateTime().Nullable() + .WithColumn("EndReason").AsInt32().Nullable() + .WithColumn("ApproverSessionId").AsString(128).Nullable() + .WithColumn("ApproverPasskeyId").AsString(36).Nullable(); + + // At most one pending request per user (plan section 5.6), absorbed in the insert statement. + if (!Schema.Table("MfaApprovalRequests").Index("UX_MfaApprovalRequests_PendingUser").Exists()) + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_MfaApprovalRequests_PendingUser ON MfaApprovalRequests (UserId) WHERE State = 0;"); + + // The per-user rate limit and suspension read a user's recent requests. + if (!Schema.Table("MfaApprovalRequests").Index("IX_MfaApprovalRequests_UserCreated").Exists()) + Create.Index("IX_MfaApprovalRequests_UserCreated").OnTable("MfaApprovalRequests") + .OnColumn("UserId").Ascending() + .OnColumn("CreatedOnUtc").Descending(); + } + + public override void Down() => throw new System.NotSupportedException("Approval requests are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0253_AddSecurityNoticesAndFactorRecovery.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0253_AddSecurityNoticesAndFactorRecovery.cs new file mode 100644 index 000000000..2a75335ce --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0253_AddSecurityNoticesAndFactorRecovery.cs @@ -0,0 +1,74 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 12: the user-level security notice outbox (plan section 6.4) and restricted factor + /// recovery transactions (plan section 5.4). A notice holds only what it says; a recovery transaction stores only a + /// hash of its secret. + /// + [Migration(253)] + public class M0253_AddSecurityNoticesAndFactorRecovery : Migration + { + public override void Up() + { + if (!Schema.Table("SecurityNotices").Exists()) + Create.Table("SecurityNotices") + .WithColumn("SecurityNoticeId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("OccurredOnUtc").AsDateTime().NotNullable() + .WithColumn("ClientApplication").AsInt32().Nullable() + .WithColumn("InstallationLabel").AsString(256).Nullable() + .WithColumn("Region").AsString(256).Nullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("NextAttemptOnUtc").AsDateTime().NotNullable() + .WithColumn("LeaseOwner").AsString(128).Nullable() + .WithColumn("LeaseUntilUtc").AsDateTime().Nullable() + .WithColumn("SentOnUtc").AsDateTime().Nullable() + .WithColumn("LastFailure").AsString(64).Nullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable(); + + if (!Schema.Table("SecurityNotices").Index("IX_SecurityNotices_Due").Exists()) + Create.Index("IX_SecurityNotices_Due").OnTable("SecurityNotices") + .OnColumn("State").Ascending() + .OnColumn("NextAttemptOnUtc").Ascending(); + + if (!Schema.Table("SecurityNotices").Index("IX_SecurityNotices_UserKind").Exists()) + Create.Index("IX_SecurityNotices_UserKind").OnTable("SecurityNotices") + .OnColumn("UserId").Ascending() + .OnColumn("Kind").Ascending() + .OnColumn("CreatedOnUtc").Descending(); + + if (!Schema.Table("FactorRecoveryTransactions").Exists()) + Create.Table("FactorRecoveryTransactions") + .WithColumn("FactorRecoveryTransactionId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("SecretHash").AsBinary(32).NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("FirstFactorMethod").AsInt32().NotNullable() + .WithColumn("FirstFactorVerifiedOnUtc").AsDateTime().NotNullable() + .WithColumn("DepartmentSsoConfigId").AsString(128).Nullable() + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("AuthenticationGeneration").AsInt64().NotNullable() + .WithColumn("CreatedOnUtc").AsDateTime().NotNullable() + .WithColumn("ExpiresOnUtc").AsDateTime().NotNullable() + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("MaxAttempts").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CompletedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("FactorRecoveryTransactions").Index("UX_FactorRecoveryTransactions_SecretHash").Exists()) + Create.Index("UX_FactorRecoveryTransactions_SecretHash").OnTable("FactorRecoveryTransactions") + .OnColumn("SecretHash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("FactorRecoveryTransactions").Index("IX_FactorRecoveryTransactions_User").Exists()) + Create.Index("IX_FactorRecoveryTransactions_User").OnTable("FactorRecoveryTransactions") + .OnColumn("UserId").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Security notices and recovery state are security records; do not drop them."); + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0254_AddSharedSessions.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0254_AddSharedSessions.cs new file mode 100644 index 000000000..c38b7d8e1 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0254_AddSharedSessions.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 13 (sections 5.5, 10.5 and 12.5): shared vehicle tablet and workstation sessions. + /// + /// UserSessions gains the shared-session state: whether it is shared and why, the idle lock sign-in recorded, a lock + /// version that every lock advances, the locked flag, when and why it last locked, and the last operator activity. The + /// shift ceiling is the existing ExpiresOn. DepartmentSecurityPolicies gains the department's shared policy (idle lock + /// 5 minutes, shift 12 hours, no app required), and UserTotpStates records whether the authenticator was set up in a + /// shared session. Existing rows take the defaults: every existing session is personal and unlocked. + /// + [Migration(254)] + public class M0254_AddSharedSessions : Migration + { + public override void Up() + { + // Each table is checked as well as each column, so the isolated database tests can apply this over just the + // tables they create; every real database has all three. + void AddColumn(string table, string column, System.Action add) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column(column).Exists()) + add(table, column); + } + + AddColumn("UserSessions", "SharedMode", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + AddColumn("UserSessions", "SharedModeSource", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(0)); + AddColumn("UserSessions", "SharedIdleLockMinutes", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().Nullable()); + AddColumn("UserSessions", "LockVersion", (t, c) => Alter.Table(t).AddColumn(c).AsInt64().NotNullable().WithDefaultValue(0L)); + AddColumn("UserSessions", "IsLocked", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + AddColumn("UserSessions", "LockedOnUtc", (t, c) => Alter.Table(t).AddColumn(c).AsDateTime2().Nullable()); + AddColumn("UserSessions", "LockReason", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().Nullable()); + AddColumn("UserSessions", "LastOperatorActivityOn", (t, c) => Alter.Table(t).AddColumn(c).AsDateTime2().Nullable()); + + AddColumn("DepartmentSecurityPolicies", "SharedIdleLockMinutes", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(5)); + AddColumn("DepartmentSecurityPolicies", "SharedShiftHours", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(12)); + AddColumn("DepartmentSecurityPolicies", "SharedModeRequiredApps", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(0)); + + AddColumn("UserTotpStates", "EnrolledInSharedMode", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + } + + public override void Down() + { + void Drop(string table, params string[] columns) + { + foreach (var column in columns) + if (Schema.Table(table).Exists() && Schema.Table(table).Column(column).Exists()) + Delete.Column(column).FromTable(table); + } + + Drop("UserTotpStates", "EnrolledInSharedMode"); + Drop("DepartmentSecurityPolicies", "SharedIdleLockMinutes", "SharedShiftHours", "SharedModeRequiredApps"); + Drop("UserSessions", "SharedMode", "SharedModeSource", "SharedIdleLockMinutes", "LockVersion", "IsLocked", "LockedOnUtc", "LockReason", + "LastOperatorActivityOn"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0255_AddMfaActivityAndApprovalInstallations.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0255_AddMfaActivityAndApprovalInstallations.cs new file mode 100644 index 000000000..4eb0adf22 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0255_AddMfaActivityAndApprovalInstallations.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 1, slice 17 (section 6.5): the account's recent MFA activity (30-day retention), a Responder + /// installation that stopped taking approval requests, and where the current authenticator was set up. Tables and + /// columns are checked before each change, so the isolated database tests can apply this over just the tables they make. + /// + [Migration(255)] + public class M0255_AddMfaActivityAndApprovalInstallations : Migration + { + public override void Up() + { + if (!Schema.Table("UserMfaActivity").Exists()) + Create.Table("UserMfaActivity") + .WithColumn("MfaActivityId").AsString(36).PrimaryKey().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("OccurredOnUtc").AsDateTime().NotNullable() + .WithColumn("Method").AsInt32().NotNullable() + .WithColumn("Purpose").AsInt32().NotNullable() + .WithColumn("Successful").AsBoolean().NotNullable() + .WithColumn("ClientApplication").AsInt32().NotNullable() + .WithColumn("InstallationLabel").AsString(256).Nullable() + .WithColumn("SharedMode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("DepartmentId").AsInt32().Nullable() + .WithColumn("SessionId").AsString(256).Nullable() + .WithColumn("ApproverSessionId").AsString(256).Nullable() + .WithColumn("ReportedOnUtc").AsDateTime().Nullable(); + + if (!Schema.Table("UserMfaActivity").Index("IX_UserMfaActivity_UserOccurred").Exists()) + Create.Index("IX_UserMfaActivity_UserOccurred").OnTable("UserMfaActivity") + .OnColumn("UserId").Ascending() + .OnColumn("OccurredOnUtc").Descending(); + + if (!Schema.Table("UserMfaActivity").Index("IX_UserMfaActivity_Occurred").Exists()) + Create.Index("IX_UserMfaActivity_Occurred").OnTable("UserMfaActivity").OnColumn("OccurredOnUtc").Ascending(); + + if (Schema.Table("UserSessions").Exists() && !Schema.Table("UserSessions").Column("ApprovalsDisabledOnUtc").Exists()) + Alter.Table("UserSessions").AddColumn("ApprovalsDisabledOnUtc").AsDateTime2().Nullable(); + + if (Schema.Table("UserTotpStates").Exists() && !Schema.Table("UserTotpStates").Column("EnrolledClientApplication").Exists()) + Alter.Table("UserTotpStates").AddColumn("EnrolledClientApplication").AsInt32().Nullable(); + if (Schema.Table("UserTotpStates").Exists() && !Schema.Table("UserTotpStates").Column("EnrolledInstallation").Exists()) + Alter.Table("UserTotpStates").AddColumn("EnrolledInstallation").AsString(256).Nullable(); + } + + public override void Down() + { + if (Schema.Table("UserTotpStates").Exists() && Schema.Table("UserTotpStates").Column("EnrolledInstallation").Exists()) + Delete.Column("EnrolledInstallation").FromTable("UserTotpStates"); + if (Schema.Table("UserTotpStates").Exists() && Schema.Table("UserTotpStates").Column("EnrolledClientApplication").Exists()) + Delete.Column("EnrolledClientApplication").FromTable("UserTotpStates"); + if (Schema.Table("UserSessions").Exists() && Schema.Table("UserSessions").Column("ApprovalsDisabledOnUtc").Exists()) + Delete.Column("ApprovalsDisabledOnUtc").FromTable("UserSessions"); + if (Schema.Table("UserMfaActivity").Exists()) + Delete.Table("UserMfaActivity"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0256_AddLoginTransactionInstallation.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0256_AddLoginTransactionInstallation.cs new file mode 100644 index 000000000..902ae56a9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0256_AddLoginTransactionInstallation.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 4, slice 34 (section 7.9): what a sign-in's first factor established about its installation, so + /// the member's Responder, asked to approve it, sees a shared workstation's sign-in as shared and with the station's + /// label, as it already does for a locked shared session. Display only: the session still decides for itself. Columns + /// are checked before each change, so the isolated database tests can apply this over just the tables they make. + /// + [Migration(256)] + public class M0256_AddLoginTransactionInstallation : Migration + { + public override void Up() + { + if (Schema.Table("MfaLoginTransactions").Exists() && !Schema.Table("MfaLoginTransactions").Column("SharedMode").Exists()) + Alter.Table("MfaLoginTransactions").AddColumn("SharedMode").AsBoolean().NotNullable().WithDefaultValue(false); + if (Schema.Table("MfaLoginTransactions").Exists() && !Schema.Table("MfaLoginTransactions").Column("InstallationLabel").Exists()) + Alter.Table("MfaLoginTransactions").AddColumn("InstallationLabel").AsString(256).Nullable(); + } + + public override void Down() + { + if (Schema.Table("MfaLoginTransactions").Exists() && Schema.Table("MfaLoginTransactions").Column("InstallationLabel").Exists()) + Delete.Column("InstallationLabel").FromTable("MfaLoginTransactions"); + if (Schema.Table("MfaLoginTransactions").Exists() && Schema.Table("MfaLoginTransactions").Column("SharedMode").Exists()) + Delete.Column("SharedMode").FromTable("MfaLoginTransactions"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0257_AddSsoTransactionSharedInstallation.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0257_AddSsoTransactionSharedInstallation.cs new file mode 100644 index 000000000..1d33160d7 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0257_AddSsoTransactionSharedInstallation.cs @@ -0,0 +1,26 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Passkey plan Phase 4, slice 37 (section 12.5.2): whether a brokered round trip came from a shared installation, so its + /// callback can require the provider's own sign-in to be fresh and the next operator cannot ride on the last one's + /// provider session. Columns are checked before each change, so the isolated database tests can apply this over just + /// the tables they make. + /// + [Migration(257)] + public class M0257_AddSsoTransactionSharedInstallation : Migration + { + public override void Up() + { + if (Schema.Table("SsoLoginTransactions").Exists() && !Schema.Table("SsoLoginTransactions").Column("SharedInstallation").Exists()) + Alter.Table("SsoLoginTransactions").AddColumn("SharedInstallation").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + if (Schema.Table("SsoLoginTransactions").Exists() && Schema.Table("SsoLoginTransactions").Column("SharedInstallation").Exists()) + Delete.Column("SharedInstallation").FromTable("SsoLoginTransactions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0243_AddMfaFactorStatePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0243_AddMfaFactorStatePg.cs new file mode 100644 index 000000000..dbd197d5c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0243_AddMfaFactorStatePg.cs @@ -0,0 +1,39 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1 (TOTP and recovery hardening): one-time TOTP time steps and hashed, single-use recovery + /// codes. Neither table holds a TOTP seed or a plaintext recovery code. Code hashes are bytea (case-sensitive). + /// + [Migration(243)] + public class M0243_AddMfaFactorStatePg : Migration + { + public override void Up() + { + if (!Schema.Table("usertotpstates").Exists()) + Create.Table("usertotpstates") + .WithColumn("userid").AsString(128).PrimaryKey().NotNullable() + .WithColumn("lastacceptedtimestep").AsInt64().NotNullable() + .WithColumn("lastacceptedonutc").AsDateTime().NotNullable() + .WithColumn("enrolledonutc").AsDateTime().Nullable(); + + if (!Schema.Table("userrecoverycodes").Exists()) + Create.Table("userrecoverycodes") + .WithColumn("userrecoverycodeid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("codehash").AsBinary(32).NotNullable() + .WithColumn("hashversion").AsInt32().NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("usedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("userrecoverycodes").Index("ux_userrecoverycodes_userhash").Exists()) + Create.Index("ux_userrecoverycodes_userhash").OnTable("userrecoverycodes") + .OnColumn("userid").Ascending() + .OnColumn("codehash").Ascending() + .WithOptions().Unique(); + } + + public override void Down() => throw new System.NotSupportedException("MFA factor state is security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0244_AddMfaEvidenceAndChallengesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0244_AddMfaEvidenceAndChallengesPg.cs new file mode 100644 index 000000000..6680d9bca --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0244_AddMfaEvidenceAndChallengesPg.cs @@ -0,0 +1,76 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 2: single-use WebAuthn challenges and server-side MFA evidence per session. Neither + /// table holds a password, TOTP code, recovery code, assertion or token. + /// + [Migration(244)] + public class M0244_AddMfaEvidenceAndChallengesPg : Migration + { + public override void Up() + { + if (!Schema.Table("authenticationchallenges").Exists()) + Create.Table("authenticationchallenges") + .WithColumn("authenticationchallengeid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("purpose").AsInt32().NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("rpid").AsString(253).NotNullable() + .WithColumn("parentkind").AsInt32().NotNullable() + .WithColumn("parentid").AsString(160).NotNullable() + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable() + .WithColumn("lockversion").AsInt64().Nullable() + .WithColumn("optionsjson").AsString(int.MaxValue).NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("maxattempts").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("consumedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("authenticationchallenges").Index("ix_authenticationchallenges_userstate").Exists()) + Create.Index("ix_authenticationchallenges_userstate").OnTable("authenticationchallenges") + .OnColumn("userid").Ascending() + .OnColumn("state").Ascending(); + + if (!Schema.Table("authenticationchallenges").Index("ix_authenticationchallenges_expires").Exists()) + Create.Index("ix_authenticationchallenges_expires").OnTable("authenticationchallenges") + .OnColumn("expiresonutc").Ascending(); + + if (!Schema.Table("usersessionmfaevidence").Exists()) + Create.Table("usersessionmfaevidence") + .WithColumn("mfaevidenceid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("sessionkey").AsString(160).NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("method").AsInt32().NotNullable() + .WithColumn("purpose").AsInt32().NotNullable() + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("verifiedonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable() + .WithColumn("factorreference").AsString(256).Nullable() + .WithColumn("revokedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("usersessionmfaevidence").Index("ix_usersessionmfaevidence_session").Exists()) + Create.Index("ix_usersessionmfaevidence_session").OnTable("usersessionmfaevidence") + .OnColumn("sessionkey").Ascending() + .OnColumn("kind").Ascending() + .OnColumn("verifiedonutc").Descending(); + + if (!Schema.Table("usersessionmfaevidence").Index("ix_usersessionmfaevidence_user").Exists()) + Create.Index("ix_usersessionmfaevidence_user").OnTable("usersessionmfaevidence") + .OnColumn("userid").Ascending(); + + if (!Schema.Table("usersessionmfaevidence").Index("ix_usersessionmfaevidence_expires").Exists()) + Create.Index("ix_usersessionmfaevidence_expires").OnTable("usersessionmfaevidence") + .OnColumn("expiresonutc").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("MFA evidence and challenges are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0245_AddBrokerReplayKeysPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0245_AddBrokerReplayKeysPg.cs new file mode 100644 index 000000000..2bab37ba4 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0245_AddBrokerReplayKeysPg.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 3: the shared, single-use record of Protected Data Broker request ids and session + /// assertion ids, replacing the broker's per-process replay cache. Keys are SHA-256 digests; no request content, + /// token, or user identifier is stored. + /// + [Migration(245)] + public class M0245_AddBrokerReplayKeysPg : Migration + { + public override void Up() + { + if (!Schema.Table("brokerreplaykeys").Exists()) + Create.Table("brokerreplaykeys") + .WithColumn("replaykey").AsString(64).PrimaryKey().NotNullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable(); + + if (!Schema.Table("brokerreplaykeys").Index("ix_brokerreplaykeys_expires").Exists()) + Create.Index("ix_brokerreplaykeys_expires").OnTable("brokerreplaykeys") + .OnColumn("expiresonutc").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Replay records are security state; they expire on their own."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0246_AddUserMfaPreferencesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0246_AddUserMfaPreferencesPg.cs new file mode 100644 index 000000000..1e2250686 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0246_AddUserMfaPreferencesPg.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 5: each user's last successful MFA method, shown as the default choice on any + /// installation (plan section 7.5 rule 5). Non-secret account metadata. + /// + [Migration(246)] + public class M0246_AddUserMfaPreferencesPg : Migration + { + public override void Up() + { + if (!Schema.Table("usermfapreferences").Exists()) + Create.Table("usermfapreferences") + .WithColumn("userid").AsString(128).PrimaryKey().NotNullable() + .WithColumn("preferredmethod").AsInt32().NotNullable() + .WithColumn("updatedonutc").AsDateTime().NotNullable(); + } + + public override void Down() => Delete.Table("usermfapreferences"); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0247_AddSecurityPolicyMfaSwitchesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0247_AddSecurityPolicyMfaSwitchesPg.cs new file mode 100644 index 000000000..3e7c2ca8d --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0247_AddSecurityPolicyMfaSwitchesPg.cs @@ -0,0 +1,41 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 6 (section 10.1): which second factors a department accepts, next to RequireMfa and + /// RequireSso, and the MFA policy version the server advances whenever the sign-in method rules change. Existing rows + /// take the defaults, which is also how a department with no row behaves. + /// + [Migration(247)] + public class M0247_AddSecurityPolicyMfaSwitchesPg : Migration + { + public override void Up() + { + void AddFlag(string column, bool defaultValue) + { + if (!Schema.Table("departmentsecuritypolicies").Column(column).Exists()) + Alter.Table("departmentsecuritypolicies").AddColumn(column).AsBoolean().NotNullable().WithDefaultValue(defaultValue); + } + + AddFlag("allowpasskeysforloginmfa", true); + AddFlag("allowpasskeysforadp", true); + AddFlag("allowfederatedmfaforloginmfa", false); + AddFlag("allowfederatedmfaforadp", false); + AddFlag("allowresponderapproval", true); + AddFlag("acceptrecentloginmfaforadp", true); + AddFlag("acceptrecentunlockmfaforadp", true); + + if (!Schema.Table("departmentsecuritypolicies").Column("mfapolicyversion").Exists()) + Alter.Table("departmentsecuritypolicies").AddColumn("mfapolicyversion").AsInt64().NotNullable().WithDefaultValue(0L); + } + + public override void Down() + { + foreach (var column in new[] { "allowpasskeysforloginmfa", "allowpasskeysforadp", "allowfederatedmfaforloginmfa", "allowfederatedmfaforadp", + "allowresponderapproval", "acceptrecentloginmfaforadp", "acceptrecentunlockmfaforadp", "mfapolicyversion" }) + if (Schema.Table("departmentsecuritypolicies").Column(column).Exists()) + Delete.Column(column).FromTable("departmentsecuritypolicies"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0248_AddUserPasskeysPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0248_AddUserPasskeysPg.cs new file mode 100644 index 000000000..a69781996 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0248_AddUserPasskeysPg.cs @@ -0,0 +1,63 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 7: registered passkeys (plan section 5.1). Public keys and protocol metadata only; no + /// private key, biometric data, assertion or token. A credential id is unique within its relying party, so concurrent + /// registration can never attach one credential to two users. + /// + [Migration(248)] + public class M0248_AddUserPasskeysPg : Migration + { + public override void Up() + { + if (!Schema.Table("userpasskeys").Exists()) + Create.Table("userpasskeys") + .WithColumn("userpasskeyid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("rpid").AsString(253).NotNullable() + .WithColumn("credentialid").AsBinary(1023).NotNullable() + .WithColumn("credentialidhash").AsBinary(32).NotNullable() + .WithColumn("publickey").AsBinary(int.MaxValue).NotNullable() + .WithColumn("algorithm").AsInt32().Nullable() + .WithColumn("userhandle").AsBinary(64).NotNullable() + .WithColumn("signcount").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("isbackupeligible").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("isbackedup").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("transports").AsString(128).Nullable() + .WithColumn("aaguid").AsString(36).Nullable() + .WithColumn("attestationformat").AsString(32).Nullable() + .WithColumn("displayname").AsString(100).NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("registrationplatform").AsString(128).Nullable() + .WithColumn("registrationinstallation").AsString(256).Nullable() + .WithColumn("registrationuseragentfamily").AsString(128).Nullable() + .WithColumn("registrationattachment").AsString(16).Nullable() + .WithColumn("registeredinsharedmode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("lastusedonutc").AsDateTime().Nullable() + .WithColumn("lastusedclientapplication").AsInt32().Nullable() + .WithColumn("lastusedinstallation").AsString(256).Nullable() + .WithColumn("lastusedinsharedmode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("approvalenabled").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("revokedonutc").AsDateTime().Nullable() + .WithColumn("revocationreason").AsInt32().Nullable() + .WithColumn("revokedbyuserid").AsString(128).Nullable() + .WithColumn("stateversion").AsInt64().NotNullable().WithDefaultValue(1); + + if (!Schema.Table("userpasskeys").Index("ux_userpasskeys_rpcredential").Exists()) + Create.Index("ux_userpasskeys_rpcredential").OnTable("userpasskeys") + .OnColumn("rpid").Ascending() + .OnColumn("credentialidhash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("userpasskeys").Index("ix_userpasskeys_userclient").Exists()) + Create.Index("ix_userpasskeys_userclient").OnTable("userpasskeys") + .OnColumn("userid").Ascending() + .OnColumn("clientapplication").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Registered passkeys are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0249_AddMfaLoginTransactionsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0249_AddMfaLoginTransactionsPg.cs new file mode 100644 index 000000000..276f7c5bd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0249_AddMfaLoginTransactionsPg.cs @@ -0,0 +1,65 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 8: restricted login MFA transactions (plan section 5.2), and the second factor on each session. Only SHA-256 hashes of the + /// transaction secret and the one-use completion code are stored; no password, code, assertion or token. + /// + [Migration(249)] + public class M0249_AddMfaLoginTransactionsPg : Migration + { + public override void Up() + { + if (!Schema.Table("mfalogintransactions").Exists()) + Create.Table("mfalogintransactions") + .WithColumn("mfalogintransactionid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("secrethash").AsBinary(32).NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("clientid").AsString(128).Nullable() + .WithColumn("firstfactormethod").AsInt32().NotNullable() + .WithColumn("firstfactorverifiedonutc").AsDateTime().NotNullable() + .WithColumn("departmentssoconfigid").AsString(128).Nullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable() + .WithColumn("mfapolicyversion").AsInt64().NotNullable().WithDefaultValue(0) + .WithColumn("scopes").AsString(512).Nullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("maxattempts").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("completionmethod").AsInt32().Nullable() + .WithColumn("completionfactorreference").AsString(256).Nullable() + .WithColumn("completionverifiedonutc").AsDateTime().Nullable() + .WithColumn("isrecovery").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("completioncodehash").AsBinary(32).Nullable() + .WithColumn("completionexpiresonutc").AsDateTime().Nullable() + .WithColumn("redeemedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("mfalogintransactions").Index("ux_mfalogintransactions_secrethash").Exists()) + Create.Index("ux_mfalogintransactions_secrethash").OnTable("mfalogintransactions") + .OnColumn("secrethash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("mfalogintransactions").Index("ix_mfalogintransactions_user").Exists()) + Create.Index("ix_mfalogintransactions_user").OnTable("mfalogintransactions") + .OnColumn("userid").Ascending(); + + if (!Schema.Table("mfalogintransactions").Index("ix_mfalogintransactions_expires").Exists()) + Create.Index("ix_mfalogintransactions_expires").OnTable("mfalogintransactions") + .OnColumn("expiresonutc").Ascending(); + + // The second factor a session's sign-in verified, apart from its first-factor AuthenticationMethod (plan + // section 5.3), so removing a passkey can end the sessions that signed in with it. + if (!Schema.Table("usersessions").Column("loginmfamethod").Exists()) + Alter.Table("usersessions").AddColumn("loginmfamethod").AsInt32().Nullable(); + + if (!Schema.Table("usersessions").Column("loginmfafactorreference").Exists()) + Alter.Table("usersessions").AddColumn("loginmfafactorreference").AsString(256).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Login transactions are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0250_AddBrokeredSsoPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0250_AddBrokeredSsoPg.cs new file mode 100644 index 000000000..df191eabd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0250_AddBrokeredSsoPg.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 9: server-brokered SSO transactions (plan section 7.7.2) and the SAML IdP SSO URL. Only + /// hashes of the IdP state, nonce and one-time code are stored, with the IdP PKCE verifier encrypted; no IdP token, + /// assertion or password. + /// + [Migration(250)] + public class M0250_AddBrokeredSsoPg : Migration + { + public override void Up() + { + if (!Schema.Table("ssologintransactions").Exists()) + Create.Table("ssologintransactions") + .WithColumn("ssologintransactionid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("statehash").AsBinary(32).NotNullable() + .WithColumn("purpose").AsInt32().NotNullable() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("departmentssoconfigid").AsString(128).NotNullable() + .WithColumn("providertype").AsInt32().NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("platform").AsString(32).Nullable() + .WithColumn("returntarget").AsString(1024).NotNullable() + .WithColumn("clientstate").AsString(512).Nullable() + .WithColumn("codechallenge").AsString(128).NotNullable() + .WithColumn("noncehash").AsBinary(32).Nullable() + .WithColumn("encryptedidpcodeverifier").AsString(1024).Nullable() + .WithColumn("samlrequestid").AsString(128).Nullable() + .WithColumn("sessionid").AsString(128).Nullable() + .WithColumn("expecteduserid").AsString(128).Nullable() + .WithColumn("authenticationgeneration").AsInt64().Nullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("userid").AsString(128).Nullable() + .WithColumn("authenticatedonutc").AsDateTime().Nullable() + .WithColumn("codehash").AsBinary(32).Nullable() + .WithColumn("codeexpiresonutc").AsDateTime().Nullable() + .WithColumn("redeemedonutc").AsDateTime().Nullable() + .WithColumn("failurecode").AsString(64).Nullable(); + + if (!Schema.Table("ssologintransactions").Index("ux_ssologintransactions_statehash").Exists()) + Create.Index("ux_ssologintransactions_statehash").OnTable("ssologintransactions") + .OnColumn("statehash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("ssologintransactions").Index("ix_ssologintransactions_expires").Exists()) + Create.Index("ix_ssologintransactions_expires").OnTable("ssologintransactions") + .OnColumn("expiresonutc").Ascending(); + + // The IdP's SAML single sign-on URL for SP-initiated (brokered) AuthnRequests. + if (!Schema.Table("departmentssoconfigs").Column("idpssourl").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("idpssourl").AsString(1024).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Brokered SSO transactions are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0251_AddFederatedMfaMappingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0251_AddFederatedMfaMappingPg.cs new file mode 100644 index 000000000..b2901fc7e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0251_AddFederatedMfaMappingPg.cs @@ -0,0 +1,46 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 10: provider step-up (federated MFA). The department's mapping and its test result, and + /// the brokered transaction's step-up binding. No IdP token, assertion or secret. + /// + [Migration(251)] + public class M0251_AddFederatedMfaMappingPg : Migration + { + public override void Up() + { + // Provider step-up mapping and its test-before-enable state (plan section 7.8). Existing rows have no mapping. + if (!Schema.Table("departmentssoconfigs").Column("federatedmfamappingjson").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("federatedmfamappingjson").AsString(int.MaxValue).Nullable(); + + if (!Schema.Table("departmentssoconfigs").Column("federatedmfamappingversion").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("federatedmfamappingversion").AsInt64().NotNullable().WithDefaultValue(0); + + if (!Schema.Table("departmentssoconfigs").Column("federatedmfatestedversion").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("federatedmfatestedversion").AsInt64().Nullable(); + + if (!Schema.Table("departmentssoconfigs").Column("federatedmfatestedonutc").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("federatedmfatestedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("departmentssoconfigs").Column("federatedmfatestedbyuserid").Exists()) + Alter.Table("departmentssoconfigs").AddColumn("federatedmfatestedbyuserid").AsString(128).Nullable(); + + // Brokered step-up binding and the value the mapping counted as MFA ("kind:value"; a value may be 256 characters). + if (!Schema.Table("ssologintransactions").Column("operation").Exists()) + Alter.Table("ssologintransactions").AddColumn("operation").AsString(64).Nullable(); + + if (!Schema.Table("ssologintransactions").Column("logintransactionid").Exists()) + Alter.Table("ssologintransactions").AddColumn("logintransactionid").AsString(36).Nullable(); + + if (!Schema.Table("ssologintransactions").Column("federatedmappingversion").Exists()) + Alter.Table("ssologintransactions").AddColumn("federatedmappingversion").AsInt64().Nullable(); + + if (!Schema.Table("ssologintransactions").Column("federatedmfavalue").Exists()) + Alter.Table("ssologintransactions").AddColumn("federatedmfavalue").AsString(512).Nullable(); + } + + public override void Down() => throw new System.NotSupportedException("Provider step-up state is security configuration; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0252_AddMfaApprovalRequestsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0252_AddMfaApprovalRequestsPg.cs new file mode 100644 index 000000000..b4f88a34c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0252_AddMfaApprovalRequestsPg.cs @@ -0,0 +1,51 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 11: Responder approval requests (plan section 5.6). The match number is stored only as a + /// hash bound to its request; no assertion, token or push payload is kept. + /// + [Migration(252)] + public class M0252_AddMfaApprovalRequestsPg : Migration + { + public override void Up() + { + if (!Schema.Table("mfaapprovalrequests").Exists()) + Create.Table("mfaapprovalrequests") + .WithColumn("mfaapprovalrequestid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("requesterkind").AsInt32().NotNullable() + .WithColumn("requesterid").AsString(128).NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("installationlabel").AsString(256).Nullable() + .WithColumn("sharedmode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("purpose").AsInt32().NotNullable() + .WithColumn("operation").AsString(64).Nullable() + .WithColumn("lockversion").AsInt64().Nullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable() + .WithColumn("matchnumberhash").AsBinary(32).NotNullable() + .WithColumn("originregion").AsString(256).Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("version").AsInt64().NotNullable().WithDefaultValue(1) + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("maxattempts").AsInt32().NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("decidedonutc").AsDateTime().Nullable() + .WithColumn("consumedonutc").AsDateTime().Nullable() + .WithColumn("endreason").AsInt32().Nullable() + .WithColumn("approversessionid").AsString(128).Nullable() + .WithColumn("approverpasskeyid").AsString(36).Nullable(); + + // At most one pending request per user (plan section 5.6), absorbed in the insert statement. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_mfaapprovalrequests_pendinguser ON mfaapprovalrequests (userid) WHERE state = 0;"); + + // The per-user rate limit and suspension read a user's recent requests. + Execute.Sql("CREATE INDEX IF NOT EXISTS ix_mfaapprovalrequests_usercreated ON mfaapprovalrequests (userid, createdonutc DESC);"); + } + + public override void Down() => throw new System.NotSupportedException("Approval requests are security state; disable the feature instead of dropping it."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0253_AddSecurityNoticesAndFactorRecoveryPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0253_AddSecurityNoticesAndFactorRecoveryPg.cs new file mode 100644 index 000000000..741164236 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0253_AddSecurityNoticesAndFactorRecoveryPg.cs @@ -0,0 +1,74 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 12: the user-level security notice outbox (plan section 6.4) and restricted factor + /// recovery transactions (plan section 5.4). A notice holds only what it says; a recovery transaction stores only a + /// hash of its secret. + /// + [Migration(253)] + public class M0253_AddSecurityNoticesAndFactorRecoveryPg : Migration + { + public override void Up() + { + if (!Schema.Table("securitynotices").Exists()) + Create.Table("securitynotices") + .WithColumn("securitynoticeid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("occurredonutc").AsDateTime().NotNullable() + .WithColumn("clientapplication").AsInt32().Nullable() + .WithColumn("installationlabel").AsString(256).Nullable() + .WithColumn("region").AsString(256).Nullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("nextattemptonutc").AsDateTime().NotNullable() + .WithColumn("leaseowner").AsString(128).Nullable() + .WithColumn("leaseuntilutc").AsDateTime().Nullable() + .WithColumn("sentonutc").AsDateTime().Nullable() + .WithColumn("lastfailure").AsString(64).Nullable() + .WithColumn("createdonutc").AsDateTime().NotNullable(); + + if (!Schema.Table("securitynotices").Index("ix_securitynotices_due").Exists()) + Create.Index("ix_securitynotices_due").OnTable("securitynotices") + .OnColumn("state").Ascending() + .OnColumn("nextattemptonutc").Ascending(); + + if (!Schema.Table("securitynotices").Index("ix_securitynotices_userkind").Exists()) + Create.Index("ix_securitynotices_userkind").OnTable("securitynotices") + .OnColumn("userid").Ascending() + .OnColumn("kind").Ascending() + .OnColumn("createdonutc").Descending(); + + if (!Schema.Table("factorrecoverytransactions").Exists()) + Create.Table("factorrecoverytransactions") + .WithColumn("factorrecoverytransactionid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("secrethash").AsBinary(32).NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("firstfactormethod").AsInt32().NotNullable() + .WithColumn("firstfactorverifiedonutc").AsDateTime().NotNullable() + .WithColumn("departmentssoconfigid").AsString(128).Nullable() + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("authenticationgeneration").AsInt64().NotNullable() + .WithColumn("createdonutc").AsDateTime().NotNullable() + .WithColumn("expiresonutc").AsDateTime().NotNullable() + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("maxattempts").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("completedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("factorrecoverytransactions").Index("ux_factorrecoverytransactions_secrethash").Exists()) + Create.Index("ux_factorrecoverytransactions_secrethash").OnTable("factorrecoverytransactions") + .OnColumn("secrethash").Ascending() + .WithOptions().Unique(); + + if (!Schema.Table("factorrecoverytransactions").Index("ix_factorrecoverytransactions_user").Exists()) + Create.Index("ix_factorrecoverytransactions_user").OnTable("factorrecoverytransactions") + .OnColumn("userid").Ascending(); + } + + public override void Down() => throw new System.NotSupportedException("Security notices and recovery state are security records; do not drop them."); + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0254_AddSharedSessionsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0254_AddSharedSessionsPg.cs new file mode 100644 index 000000000..a9f189071 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0254_AddSharedSessionsPg.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 13 (sections 5.5, 10.5 and 12.5): shared vehicle tablet and workstation sessions. + /// + /// UserSessions gains the shared-session state: whether it is shared and why, the idle lock sign-in recorded, a lock + /// version that every lock advances, the locked flag, when and why it last locked, and the last operator activity. The + /// shift ceiling is the existing ExpiresOn. DepartmentSecurityPolicies gains the department's shared policy (idle lock + /// 5 minutes, shift 12 hours, no app required), and UserTotpStates records whether the authenticator was set up in a + /// shared session. Existing rows take the defaults: every existing session is personal and unlocked. + /// + [Migration(254)] + public class M0254_AddSharedSessionsPg : Migration + { + public override void Up() + { + // Each table is checked as well as each column, so the isolated database tests can apply this over just the + // tables they create; every real database has all three. + void AddColumn(string table, string column, System.Action add) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column(column).Exists()) + add(table, column); + } + + AddColumn("usersessions", "sharedmode", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + AddColumn("usersessions", "sharedmodesource", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(0)); + AddColumn("usersessions", "sharedidlelockminutes", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().Nullable()); + AddColumn("usersessions", "lockversion", (t, c) => Alter.Table(t).AddColumn(c).AsInt64().NotNullable().WithDefaultValue(0L)); + AddColumn("usersessions", "islocked", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + AddColumn("usersessions", "lockedonutc", (t, c) => Alter.Table(t).AddColumn(c).AsDateTime2().Nullable()); + AddColumn("usersessions", "lockreason", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().Nullable()); + AddColumn("usersessions", "lastoperatoractivityon", (t, c) => Alter.Table(t).AddColumn(c).AsDateTime2().Nullable()); + + AddColumn("departmentsecuritypolicies", "sharedidlelockminutes", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(5)); + AddColumn("departmentsecuritypolicies", "sharedshifthours", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(12)); + AddColumn("departmentsecuritypolicies", "sharedmoderequiredapps", (t, c) => Alter.Table(t).AddColumn(c).AsInt32().NotNullable().WithDefaultValue(0)); + + AddColumn("usertotpstates", "enrolledinsharedmode", (t, c) => Alter.Table(t).AddColumn(c).AsBoolean().NotNullable().WithDefaultValue(false)); + } + + public override void Down() + { + void Drop(string table, params string[] columns) + { + foreach (var column in columns) + if (Schema.Table(table).Exists() && Schema.Table(table).Column(column).Exists()) + Delete.Column(column).FromTable(table); + } + + Drop("usertotpstates", "enrolledinsharedmode"); + Drop("departmentsecuritypolicies", "sharedidlelockminutes", "sharedshifthours", "sharedmoderequiredapps"); + Drop("usersessions", "sharedmode", "sharedmodesource", "sharedidlelockminutes", "lockversion", "islocked", "lockedonutc", "lockreason", + "lastoperatoractivityon"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0255_AddMfaActivityAndApprovalInstallationsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0255_AddMfaActivityAndApprovalInstallationsPg.cs new file mode 100644 index 000000000..28efd548f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0255_AddMfaActivityAndApprovalInstallationsPg.cs @@ -0,0 +1,60 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 1, slice 17 (section 6.5): the account's recent MFA activity (30-day retention), a Responder + /// installation that stopped taking approval requests, and where the current authenticator was set up. Tables and + /// columns are checked before each change, so the isolated database tests can apply this over just the tables they make. + /// + [Migration(255)] + public class M0255_AddMfaActivityAndApprovalInstallationsPg : Migration + { + public override void Up() + { + if (!Schema.Table("usermfaactivity").Exists()) + Create.Table("usermfaactivity") + .WithColumn("mfaactivityid").AsString(36).PrimaryKey().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("occurredonutc").AsDateTime().NotNullable() + .WithColumn("method").AsInt32().NotNullable() + .WithColumn("purpose").AsInt32().NotNullable() + .WithColumn("successful").AsBoolean().NotNullable() + .WithColumn("clientapplication").AsInt32().NotNullable() + .WithColumn("installationlabel").AsString(256).Nullable() + .WithColumn("sharedmode").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("departmentid").AsInt32().Nullable() + .WithColumn("sessionid").AsString(256).Nullable() + .WithColumn("approversessionid").AsString(256).Nullable() + .WithColumn("reportedonutc").AsDateTime().Nullable(); + + if (!Schema.Table("usermfaactivity").Index("ix_usermfaactivity_useroccurred").Exists()) + Create.Index("ix_usermfaactivity_useroccurred").OnTable("usermfaactivity") + .OnColumn("userid").Ascending() + .OnColumn("occurredonutc").Descending(); + + if (!Schema.Table("usermfaactivity").Index("ix_usermfaactivity_occurred").Exists()) + Create.Index("ix_usermfaactivity_occurred").OnTable("usermfaactivity").OnColumn("occurredonutc").Ascending(); + + if (Schema.Table("usersessions").Exists() && !Schema.Table("usersessions").Column("approvalsdisabledonutc").Exists()) + Alter.Table("usersessions").AddColumn("approvalsdisabledonutc").AsDateTime2().Nullable(); + + if (Schema.Table("usertotpstates").Exists() && !Schema.Table("usertotpstates").Column("enrolledclientapplication").Exists()) + Alter.Table("usertotpstates").AddColumn("enrolledclientapplication").AsInt32().Nullable(); + if (Schema.Table("usertotpstates").Exists() && !Schema.Table("usertotpstates").Column("enrolledinstallation").Exists()) + Alter.Table("usertotpstates").AddColumn("enrolledinstallation").AsString(256).Nullable(); + } + + public override void Down() + { + if (Schema.Table("usertotpstates").Exists() && Schema.Table("usertotpstates").Column("enrolledinstallation").Exists()) + Delete.Column("enrolledinstallation").FromTable("usertotpstates"); + if (Schema.Table("usertotpstates").Exists() && Schema.Table("usertotpstates").Column("enrolledclientapplication").Exists()) + Delete.Column("enrolledclientapplication").FromTable("usertotpstates"); + if (Schema.Table("usersessions").Exists() && Schema.Table("usersessions").Column("approvalsdisabledonutc").Exists()) + Delete.Column("approvalsdisabledonutc").FromTable("usersessions"); + if (Schema.Table("usermfaactivity").Exists()) + Delete.Table("usermfaactivity"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0256_AddLoginTransactionInstallationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0256_AddLoginTransactionInstallationPg.cs new file mode 100644 index 000000000..b6ed326ab --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0256_AddLoginTransactionInstallationPg.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 4, slice 34 (section 7.9): what a sign-in's first factor established about its installation, so + /// the member's Responder, asked to approve it, sees a shared workstation's sign-in as shared and with the station's + /// label, as it already does for a locked shared session. Display only: the session still decides for itself. Columns + /// are checked before each change, so the isolated database tests can apply this over just the tables they make. + /// + [Migration(256)] + public class M0256_AddLoginTransactionInstallationPg : Migration + { + public override void Up() + { + if (Schema.Table("mfalogintransactions").Exists() && !Schema.Table("mfalogintransactions").Column("sharedmode").Exists()) + Alter.Table("mfalogintransactions").AddColumn("sharedmode").AsBoolean().NotNullable().WithDefaultValue(false); + if (Schema.Table("mfalogintransactions").Exists() && !Schema.Table("mfalogintransactions").Column("installationlabel").Exists()) + Alter.Table("mfalogintransactions").AddColumn("installationlabel").AsString(256).Nullable(); + } + + public override void Down() + { + if (Schema.Table("mfalogintransactions").Exists() && Schema.Table("mfalogintransactions").Column("installationlabel").Exists()) + Delete.Column("installationlabel").FromTable("mfalogintransactions"); + if (Schema.Table("mfalogintransactions").Exists() && Schema.Table("mfalogintransactions").Column("sharedmode").Exists()) + Delete.Column("sharedmode").FromTable("mfalogintransactions"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0257_AddSsoTransactionSharedInstallationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0257_AddSsoTransactionSharedInstallationPg.cs new file mode 100644 index 000000000..76bf6f9fc --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0257_AddSsoTransactionSharedInstallationPg.cs @@ -0,0 +1,26 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Passkey plan Phase 4, slice 37 (section 12.5.2): whether a brokered round trip came from a shared installation, so its + /// callback can require the provider's own sign-in to be fresh and the next operator cannot ride on the last one's + /// provider session. Columns are checked before each change, so the isolated database tests can apply this over just + /// the tables they make. + /// + [Migration(257)] + public class M0257_AddSsoTransactionSharedInstallationPg : Migration + { + public override void Up() + { + if (Schema.Table("ssologintransactions").Exists() && !Schema.Table("ssologintransactions").Column("sharedinstallation").Exists()) + Alter.Table("ssologintransactions").AddColumn("sharedinstallation").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + if (Schema.Table("ssologintransactions").Exists() && Schema.Table("ssologintransactions").Column("sharedinstallation").Exists()) + Delete.Column("sharedinstallation").FromTable("ssologintransactions"); + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs index 4d04a9b35..d7f624903 100644 --- a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs @@ -11,6 +11,8 @@ using Resgrid.Model.Providers; using Resgrid.Model; using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; namespace Resgrid.Providers.ProtectedData { @@ -25,6 +27,11 @@ namespace Resgrid.Providers.ProtectedData public class ProtectedDataBrokerClient : IProtectedDataBrokerClient, IDisposable { internal const string WorkloadKeyHeader = "X-Resgrid-Broker-Key"; + internal const string ClientIdHeader = "X-Resgrid-Broker-Client"; + internal const string HostHeader = "X-Resgrid-Broker-Host"; + + // Informational only: lets the broker's legacy-key log name the calling process during the migration window. + private static readonly string HostName = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name ?? "unknown"; internal const string BrokerUnavailableErrorCode = "broker_unavailable"; // The client is scoped (its audit repository is), so the connection pool must outlive it: a handler per @@ -36,21 +43,28 @@ public class ProtectedDataBrokerClient : IProtectedDataBrokerClient, IDisposable private readonly HttpClient _httpClient; private readonly IAdpAuditRepository _audit; + private readonly IBrokerSessionAssertionService _assertions; + private readonly IProtectedGrantContext _grantContext; - public ProtectedDataBrokerClient(IAdpAuditRepository audit) - : this(SharedHandler, audit, disposeHandler: false) + public ProtectedDataBrokerClient(IAdpAuditRepository audit, IBrokerSessionAssertionService assertions, + IProtectedGrantContext grantContext) + : this(SharedHandler, audit, disposeHandler: false, assertions, grantContext) { } /// Test seam: inject a message handler. - public ProtectedDataBrokerClient(HttpMessageHandler handler, IAdpAuditRepository audit) - : this(handler, audit, disposeHandler: true) + public ProtectedDataBrokerClient(HttpMessageHandler handler, IAdpAuditRepository audit, + IBrokerSessionAssertionService assertions = null, IProtectedGrantContext grantContext = null) + : this(handler, audit, disposeHandler: true, assertions, grantContext) { } - private ProtectedDataBrokerClient(HttpMessageHandler handler, IAdpAuditRepository audit, bool disposeHandler) + private ProtectedDataBrokerClient(HttpMessageHandler handler, IAdpAuditRepository audit, bool disposeHandler, + IBrokerSessionAssertionService assertions, IProtectedGrantContext grantContext) { _audit = audit; + _assertions = assertions; + _grantContext = grantContext; _httpClient = new HttpClient(handler, disposeHandler) { Timeout = TimeSpan.FromMilliseconds(DataProtectionConfig.BrokerTimeoutMs > 0 @@ -165,7 +179,11 @@ private async Task SendCoreAsync(string path, int dep { Content = new StringContent(payload, Encoding.UTF8, "application/json") }; - request.Headers.TryAddWithoutValidation(WorkloadKeyHeader, DataProtectionConfig.BrokerApiKey); + AddCredentialHeaders(request); + + var assertion = TryMintSessionAssertion(path, departmentId, grantToken, requestId, items); + if (assertion != null) + request.Headers.TryAddWithoutValidation(BrokerSessionAssertion.HeaderName, assertion); using var response = await _httpClient.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); @@ -202,6 +220,66 @@ private async Task SendCoreAsync(string path, int dep } } + /// + /// This host's broker credential (passkey plan section 8.5): its own id and key when configured, otherwise the + /// legacy shared key, which the broker accepts only during the migration window. + /// + internal static void AddCredentialHeaders(HttpRequestMessage request) + { + if (!string.IsNullOrWhiteSpace(DataProtectionConfig.BrokerClientId) && !string.IsNullOrWhiteSpace(DataProtectionConfig.BrokerClientKey)) + { + request.Headers.TryAddWithoutValidation(ClientIdHeader, DataProtectionConfig.BrokerClientId.Trim()); + request.Headers.TryAddWithoutValidation(WorkloadKeyHeader, DataProtectionConfig.BrokerClientKey); + } + else + { + request.Headers.TryAddWithoutValidation(WorkloadKeyHeader, DataProtectionConfig.BrokerApiKey); + } + + request.Headers.TryAddWithoutValidation(HostHeader, HostName); + } + + /// + /// The identity tier's statement of which live session is behind an attended request (passkey workbook section + /// 6.2), minted only when a user grant is presented and the request passed session validation. Workload calls and + /// release receipts carry none. A mint failure sends the request without one; the broker decides whether that is + /// acceptable (never for a version 2 grant). + /// + private string TryMintSessionAssertion(string path, int departmentId, string grantToken, string requestId, + IReadOnlyList items) + { + if (_assertions == null || _grantContext == null || string.IsNullOrWhiteSpace(grantToken) || + grantToken.StartsWith("adpr.", StringComparison.Ordinal) || _grantContext.IsWorkloadCaller) + return null; + + var operation = path.EndsWith("/decrypt", StringComparison.Ordinal) ? "decrypt" + : path.EndsWith("/encrypt", StringComparison.Ordinal) ? "encrypt" : null; + var session = _grantContext.Session; + if (operation == null || session == null || string.IsNullOrWhiteSpace(_grantContext.UserId) || !_assertions.CanMint) + return null; + + try + { + return _assertions.Mint(new BrokerSessionAssertion + { + UserId = _grantContext.UserId, + SessionId = session.SessionId, + AuthenticationGeneration = session.AuthenticationGeneration, + DepartmentId = departmentId, + ClientApplication = session.ClientApplication, + SessionLockVersion = session.SessionLockVersion, + CredentialIssuedOnUtc = session.CredentialIssuedOnUtc, + RequestDigest = BrokerRequestDigest.Compute(operation, departmentId, requestId, items) + }); + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException || + ex is System.Security.Cryptography.CryptographicException) + { + Logging.LogError($"Broker session assertion could not be minted: {ex.GetType().Name}."); + return null; + } + } + private static ProtectedDataBrokerResult TryDeserialize(string body) { try diff --git a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs index d259f0530..db69ef114 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/AdminAssistRepository.SecurityImpact.cs @@ -12,7 +12,9 @@ public sealed partial class AdminAssistRepository public async Task ReadSecurityPolicyAsync(int departmentId, CancellationToken ct) { if (departmentId <= 0) throw new ArgumentException("Invalid department."); - var rows = (await QueryAsync($"SELECT {(IsPostgres ? "" : "TOP (2) ")}{Cols("DepartmentId", "RequireMfa", "RequireSso", "SessionTimeoutMinutes", "MaxConcurrentSessions", "PasswordExpirationDays", "MinPasswordLength")} " + + var rows = (await QueryAsync($"SELECT {(IsPostgres ? "" : "TOP (2) ")}{Cols("DepartmentId", "RequireMfa", "RequireSso", "SessionTimeoutMinutes", "MaxConcurrentSessions", "PasswordExpirationDays", "MinPasswordLength", + "AllowPasskeysForLoginMfa", "AllowPasskeysForAdp", "AllowFederatedMfaForLoginMfa", "AllowFederatedMfaForAdp", "AllowResponderApproval", + "AcceptRecentLoginMfaForAdp", "AcceptRecentUnlockMfaForAdp", "MfaPolicyVersion")} " + $"FROM {Tbl("DepartmentSecurityPolicies")} WHERE {Col("DepartmentId")}={P}DepartmentId{(IsPostgres ? " LIMIT 2" : "")}", new { DepartmentId = departmentId }, ct)).ToList(); return rows.SingleOrDefault(); } diff --git a/Repositories/Resgrid.Repositories.DataRepository/AuthenticationChallengeRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/AuthenticationChallengeRepository.cs new file mode 100644 index 000000000..364df7a2b --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/AuthenticationChallengeRepository.cs @@ -0,0 +1,126 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Single-use WebAuthn challenges (workbook section 8.3): every state change is one guarded UPDATE whose success is + /// exactly one row, on its own connection, so two nodes can never both spend one challenge. + /// + public sealed class AuthenticationChallengeRepository : IAuthenticationChallengeRepository + { + private const int Pending = (int)AuthenticationChallengeState.Pending; + private const int Consumed = (int)AuthenticationChallengeState.Consumed; + private const int Exhausted = (int)AuthenticationChallengeState.Exhausted; + private const int Canceled = (int)AuthenticationChallengeState.Canceled; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public AuthenticationChallengeRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".authenticationchallenges" : ".[AuthenticationChallenges]"); + } + + public async Task InsertAsync(AuthenticationChallenge challenge, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (AuthenticationChallengeId, UserId, Purpose, ClientApplication, RpId, ParentKind, ParentId, + DepartmentId, AuthenticationGeneration, LockVersion, OptionsJson, CreatedOnUtc, ExpiresOnUtc, Attempts, MaxAttempts, State) + VALUES (@AuthenticationChallengeId, @UserId, @Purpose, @ClientApplication, @RpId, @ParentKind, @ParentId, + @DepartmentId, @AuthenticationGeneration, @LockVersion, @OptionsJson, @CreatedOnUtc, @ExpiresOnUtc, 0, @MaxAttempts, {Pending})", + new + { + challenge.AuthenticationChallengeId, + challenge.UserId, + challenge.Purpose, + challenge.ClientApplication, + challenge.RpId, + challenge.ParentKind, + challenge.ParentId, + challenge.DepartmentId, + challenge.AuthenticationGeneration, + challenge.LockVersion, + challenge.OptionsJson, + CreatedOnUtc = Timestamp(challenge.CreatedOnUtc), + ExpiresOnUtc = Timestamp(challenge.ExpiresOnUtc), + challenge.MaxAttempts + }, cancellationToken: cancellationToken)); + } + + public async Task GetAsync(string challengeId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $@"SELECT AuthenticationChallengeId, UserId, Purpose, ClientApplication, RpId, ParentKind, ParentId, DepartmentId, + AuthenticationGeneration, LockVersion, OptionsJson, CreatedOnUtc, ExpiresOnUtc, Attempts, MaxAttempts, State, ConsumedOnUtc + FROM {_table} WHERE AuthenticationChallengeId = @Id", + new { Id = challengeId }, cancellationToken: cancellationToken)); + } + + public async Task CountPendingForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_table} WHERE UserId = @UserId AND State = {Pending} AND ExpiresOnUtc > @Now", + new { UserId = userId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task TryConsumeAsync(string challengeId, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Consumed}, ConsumedOnUtc = @Now + WHERE AuthenticationChallengeId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now AND Attempts < MaxAttempts", + new { Id = challengeId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task RecordFailedAttemptAsync(string challengeId, CancellationToken cancellationToken = default) + { + // Both engines evaluate every SET expression against the pre-update row, so the limit check sees the old count. + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET Attempts = Attempts + 1, + State = CASE WHEN Attempts + 1 >= MaxAttempts THEN {Exhausted} ELSE State END + WHERE AuthenticationChallengeId = @Id AND State = {Pending}", + new { Id = challengeId }, cancellationToken: cancellationToken)); + } + + public async Task CancelPendingForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET State = {Canceled} WHERE UserId = @UserId AND State = {Pending}", + new { UserId = userId }, cancellationToken: cancellationToken)); + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/BrokerReplayRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/BrokerReplayRepository.cs new file mode 100644 index 000000000..7f0f97cd9 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/BrokerReplayRepository.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Shared broker replay record (passkey workbook sections 6.2 and 8.3): one insert-if-absent per key, on its own + /// connection, whose success is exactly one row. PostgreSQL uses ON CONFLICT DO NOTHING; SQL Server uses MERGE with + /// HOLDLOCK so two replicas racing on one key cannot both insert. + /// + public sealed class BrokerReplayRepository : IBrokerReplayRepository + { + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public BrokerReplayRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".brokerreplaykeys" : ".[BrokerReplayKeys]"); + } + + public async Task TryClaimAsync(string replayKey, BrokerReplayKind kind, DateTime expiresOnUtc, DateTime utcNow, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(replayKey) || replayKey.Length > 64) + throw new ArgumentException("A replay key is a SHA-256 digest.", nameof(replayKey)); + + var sql = _postgres + ? $@"INSERT INTO {_table} (replaykey, kind, createdonutc, expiresonutc) VALUES (@Key, @Kind, @Now, @Expires) + ON CONFLICT (replaykey) DO NOTHING" + : $@"MERGE {_table} WITH (HOLDLOCK) AS target + USING (SELECT @Key AS ReplayKey) AS source ON target.ReplayKey = source.ReplayKey + WHEN NOT MATCHED THEN + INSERT (ReplayKey, Kind, CreatedOnUtc, ExpiresOnUtc) VALUES (@Key, @Kind, @Now, @Expires);"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition(sql, new + { + Key = new DbString { Value = replayKey, IsAnsi = true, IsFixedLength = false, Length = 64 }, + Kind = (int)kind, + Now = Timestamp(utcNow), + Expires = Timestamp(expiresOnUtc) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs index 2557106a2..72da8cd25 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSecurityPolicyRepository.cs @@ -59,6 +59,34 @@ public async Task GetByDepartmentIdAsync(int departmen } catch (Exception ex) { Logging.LogException(ex); throw; } } + + public async Task GetByDepartmentIdForUpdateAsync(int departmentId, System.Threading.CancellationToken cancellationToken = default) + { + if (_unitOfWork?.Transaction == null) + throw new InvalidOperationException("A locking read needs the caller's unit-of-work transaction."); + + var postgres = Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres; + var sql = postgres + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.departmentsecuritypolicies WHERE departmentid = @DepartmentId LIMIT 1 FOR UPDATE" + : $"SELECT TOP 1 * FROM {_sqlConfiguration.SchemaName}.[DepartmentSecurityPolicies] WITH (UPDLOCK, HOLDLOCK) WHERE [DepartmentId] = @DepartmentId"; + + return await _unitOfWork.CreateOrGetConnection().QueryFirstOrDefaultAsync( + new Dapper.CommandDefinition(sql, new { DepartmentId = departmentId }, _unitOfWork.Transaction, cancellationToken: cancellationToken)); + } + + public async Task IncrementMfaPolicyVersionAsync(int departmentId, System.Threading.CancellationToken cancellationToken = default) + { + if (_unitOfWork?.Transaction == null) + throw new InvalidOperationException("The MFA policy version advances only inside the policy change's transaction."); + + var postgres = Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres; + var sql = postgres + ? $"UPDATE {_sqlConfiguration.SchemaName}.departmentsecuritypolicies SET mfapolicyversion = mfapolicyversion + 1 WHERE departmentid = @DepartmentId RETURNING mfapolicyversion" + : $"UPDATE {_sqlConfiguration.SchemaName}.[DepartmentSecurityPolicies] SET [MfaPolicyVersion] = [MfaPolicyVersion] + 1 OUTPUT INSERTED.[MfaPolicyVersion] WHERE [DepartmentId] = @DepartmentId"; + + return await _unitOfWork.CreateOrGetConnection().QueryFirstOrDefaultAsync( + new Dapper.CommandDefinition(sql, new { DepartmentId = departmentId }, _unitOfWork.Transaction, cancellationToken: cancellationToken)); + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs index 9f7828a25..211daa171 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentSsoConfigRepository.cs @@ -117,6 +117,45 @@ public async Task GetByEntityIdAsync(string entityId) } catch (Exception ex) { Logging.LogException(ex); throw; } } + + public async Task AdvanceFederatedMfaMappingVersionAsync(string departmentSsoConfigId, + System.Threading.CancellationToken cancellationToken = default) + { + // One statement advances the version and clears the test, so no reader sees a new mapping as tested. + var postgres = Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres; + var sql = postgres + ? $@"UPDATE {_sqlConfiguration.SchemaName}.departmentssoconfigs SET federatedmfamappingversion = federatedmfamappingversion + 1, + federatedmfatestedversion = NULL, federatedmfatestedonutc = NULL, federatedmfatestedbyuserid = NULL + WHERE departmentssoconfigid = @Id RETURNING federatedmfamappingversion" + : $@"UPDATE {_sqlConfiguration.SchemaName}.[DepartmentSsoConfigs] SET [FederatedMfaMappingVersion] = [FederatedMfaMappingVersion] + 1, + [FederatedMfaTestedVersion] = NULL, [FederatedMfaTestedOnUtc] = NULL, [FederatedMfaTestedByUserId] = NULL + OUTPUT INSERTED.[FederatedMfaMappingVersion] WHERE [DepartmentSsoConfigId] = @Id"; + + await using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QueryFirstOrDefaultAsync(new Dapper.CommandDefinition(sql, new { Id = departmentSsoConfigId }, + cancellationToken: cancellationToken)); + } + + public async Task TryRecordFederatedMfaTestAsync(string departmentSsoConfigId, long version, string userId, DateTime utcNow, + System.Threading.CancellationToken cancellationToken = default) + { + var postgres = Resgrid.Config.DataConfig.DatabaseType == Resgrid.Config.DatabaseTypes.Postgres; + var sql = postgres + ? $@"UPDATE {_sqlConfiguration.SchemaName}.departmentssoconfigs SET federatedmfatestedversion = @Version, federatedmfatestedonutc = @Now, + federatedmfatestedbyuserid = @UserId WHERE departmentssoconfigid = @Id AND federatedmfamappingversion = @Version" + : $@"UPDATE {_sqlConfiguration.SchemaName}.[DepartmentSsoConfigs] SET [FederatedMfaTestedVersion] = @Version, [FederatedMfaTestedOnUtc] = @Now, + [FederatedMfaTestedByUserId] = @UserId WHERE [DepartmentSsoConfigId] = @Id AND [FederatedMfaMappingVersion] = @Version"; + + await using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new + { + Id = departmentSsoConfigId, + Version = version, + UserId = userId, + Now = postgres ? DateTime.SpecifyKind(utcNow, DateTimeKind.Unspecified) : utcNow + }, cancellationToken: cancellationToken)) == 1; + } } } - diff --git a/Repositories/Resgrid.Repositories.DataRepository/FactorRecoveryTransactionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/FactorRecoveryTransactionRepository.cs new file mode 100644 index 000000000..f47f6fd42 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/FactorRecoveryTransactionRepository.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Restricted factor recovery transactions (plan section 5.4; workbook section 8.3): every state change is one guarded + /// UPDATE whose success is exactly one row, on its own connection. + /// + public sealed class FactorRecoveryTransactionRepository : IFactorRecoveryTransactionRepository + { + private const int Pending = (int)FactorRecoveryState.Pending; + private const int Completed = (int)FactorRecoveryState.Completed; + private const int Canceled = (int)FactorRecoveryState.Canceled; + private const int Exhausted = (int)FactorRecoveryState.Exhausted; + + private const string Columns = @"FactorRecoveryTransactionId, SecretHash, UserId, ClientApplication, FirstFactorMethod, FirstFactorVerifiedOnUtc, + DepartmentSsoConfigId, DepartmentId, AuthenticationGeneration, CreatedOnUtc, ExpiresOnUtc, Attempts, MaxAttempts, State, CompletedOnUtc"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public FactorRecoveryTransactionRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".factorrecoverytransactions" : ".[FactorRecoveryTransactions]"); + } + + public async Task InsertAsync(FactorRecoveryTransaction transaction, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (FactorRecoveryTransactionId, SecretHash, UserId, ClientApplication, FirstFactorMethod, FirstFactorVerifiedOnUtc, + DepartmentSsoConfigId, DepartmentId, AuthenticationGeneration, CreatedOnUtc, ExpiresOnUtc, Attempts, MaxAttempts, State) + VALUES (@FactorRecoveryTransactionId, @SecretHash, @UserId, @ClientApplication, @FirstFactorMethod, @FirstFactorVerifiedOnUtc, + @DepartmentSsoConfigId, @DepartmentId, @AuthenticationGeneration, @CreatedOnUtc, @ExpiresOnUtc, 0, @MaxAttempts, {Pending})", + new + { + transaction.FactorRecoveryTransactionId, + transaction.SecretHash, + transaction.UserId, + transaction.ClientApplication, + transaction.FirstFactorMethod, + FirstFactorVerifiedOnUtc = Timestamp(transaction.FirstFactorVerifiedOnUtc), + transaction.DepartmentSsoConfigId, + transaction.DepartmentId, + transaction.AuthenticationGeneration, + CreatedOnUtc = Timestamp(transaction.CreatedOnUtc), + ExpiresOnUtc = Timestamp(transaction.ExpiresOnUtc), + transaction.MaxAttempts + }, cancellationToken: cancellationToken)); + } + + public async Task GetBySecretHashAsync(byte[] secretHash, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE SecretHash = @Hash", new { Hash = secretHash }, cancellationToken: cancellationToken)); + } + + public async Task RecordFailedAttemptAsync(string transactionId, CancellationToken cancellationToken = default) + { + // Both engines evaluate every SET expression against the pre-update row, so the limit check sees the old count. + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET Attempts = Attempts + 1, State = CASE WHEN Attempts + 1 >= MaxAttempts THEN {Exhausted} ELSE State END + WHERE FactorRecoveryTransactionId = @Id AND State = {Pending}", + new { Id = transactionId }, cancellationToken: cancellationToken)); + } + + public async Task TryCompleteAsync(string transactionId, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Completed}, CompletedOnUtc = @Now + WHERE FactorRecoveryTransactionId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now AND Attempts < MaxAttempts", + new { Id = transactionId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryCancelAsync(string transactionId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET State = {Canceled} WHERE FactorRecoveryTransactionId = @Id AND State = {Pending}", + new { Id = transactionId }, cancellationToken: cancellationToken)) == 1; + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + public async Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE UserId = @UserId", new { UserId = userId }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs index 05a10e3a1..8cb6c5a80 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs @@ -464,6 +464,11 @@ public async Task ClearOutUserLoginAsync(string userId) await db.ExecuteAsync(@"DELETE FROM public.pushuris WHERE userid = @userId", new { userId = userId }, transaction); await db.ExecuteAsync(@"DELETE FROM public.chatbotuseridentities WHERE userid = @userId", new { userId = userId }, transaction); await db.ExecuteAsync(@"DELETE FROM public.chatbotlinkingcodes WHERE userid = @userId", new { userId = userId }, transaction); + // The authenticator key (current and staged), recovery codes and their state: no factor outlives the account. + await db.ExecuteAsync(@"DELETE FROM public.aspnetusertokens WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.userrecoverycodes WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.usertotpstates WHERE userid = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM public.usermfapreferences WHERE userid = @userId", new { userId = userId }, transaction); transaction.Commit(); } @@ -512,6 +517,11 @@ public async Task ClearOutUserLoginAsync(string userId) await db.ExecuteAsync(@"DELETE FROM PushUris WHERE UserId = @userId", new { userId = userId }, transaction); await db.ExecuteAsync(@"DELETE FROM ChatbotUserIdentities WHERE UserId = @userId", new { userId = userId }, transaction); await db.ExecuteAsync(@"DELETE FROM ChatbotLinkingCodes WHERE UserId = @userId", new { userId = userId }, transaction); + // The authenticator key (current and staged), recovery codes and their state: no factor outlives the account. + await db.ExecuteAsync(@"DELETE FROM AspNetUserTokens WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM UserRecoveryCodes WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM UserTotpStates WHERE UserId = @userId", new { userId = userId }, transaction); + await db.ExecuteAsync(@"DELETE FROM UserMfaPreferences WHERE UserId = @userId", new { userId = userId }, transaction); transaction.Commit(); } diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityUserRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityUserRepository.cs index 38169c4a3..c8d03d59c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IdentityUserRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityUserRepository.cs @@ -1182,5 +1182,40 @@ await x.ExecuteAsync( throw; } } + + public async Task TryReplaceTokenAsync(string userId, string loginProvider, string name, string expectedValue, string newValue, + CancellationToken cancellationToken) + { + // Exact comparison: SQL Server's default collation would treat base64 values that differ only in case as equal. + var sql = Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres + ? "UPDATE aspnetusertokens SET value = @NewValue WHERE userid = @UserId AND loginprovider = @LoginProvider AND name = @Name AND value = @ExpectedValue" + : "UPDATE AspNetUserTokens SET Value = @NewValue WHERE UserId = @UserId AND LoginProvider = @LoginProvider AND Name = @Name AND Value COLLATE Latin1_General_BIN2 = @ExpectedValue"; + + // A dedicated connection, as SetTokenAsync uses: the ambient unit of work may already have been committed. + await using var conn = _connectionProvider.Create(); + await conn.OpenAsync(cancellationToken); + return await conn.ExecuteAsync(new CommandDefinition(sql, + new { UserId = userId, LoginProvider = loginProvider, Name = name, ExpectedValue = expectedValue, NewValue = newValue }, + cancellationToken: cancellationToken)) == 1; + } + + public async Task> GetTokensPageAsync(string loginProvider, string name, string afterUserId, int take, + CancellationToken cancellationToken) + { + // The first page starts after "", which every user id sorts after; no nullable parameter for PostgreSQL to type. + var sql = Config.DataConfig.DatabaseType == Config.DatabaseTypes.Postgres + ? @"SELECT userid AS UserId, value AS Value FROM aspnetusertokens + WHERE loginprovider = @LoginProvider AND name = @Name AND userid > @AfterUserId + ORDER BY userid LIMIT @Take" + : @"SELECT TOP (@Take) UserId, Value FROM AspNetUserTokens + WHERE LoginProvider = @LoginProvider AND Name = @Name AND UserId > @AfterUserId + ORDER BY UserId"; + + await using var conn = _connectionProvider.Create(); + await conn.OpenAsync(cancellationToken); + var rows = await conn.QueryAsync(new CommandDefinition(sql, + new { LoginProvider = loginProvider, Name = name, AfterUserId = afterUserId ?? string.Empty, Take = Math.Max(1, take) }, cancellationToken: cancellationToken)); + return rows.ToList(); + } } } diff --git a/Repositories/Resgrid.Repositories.DataRepository/MfaActivityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/MfaActivityRepository.cs new file mode 100644 index 000000000..436c6d11b --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/MfaActivityRepository.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + public sealed class MfaActivityRepository : IMfaActivityRepository + { + private const string Columns = @"MfaActivityId, UserId, OccurredOnUtc, Method, Purpose, Successful, ClientApplication, InstallationLabel, SharedMode, + DepartmentId, SessionId, ApproverSessionId, ReportedOnUtc"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public MfaActivityRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".usermfaactivity" : ".[UserMfaActivity]"); + } + + public async Task InsertAsync(MfaActivity activity, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (MfaActivityId, UserId, OccurredOnUtc, Method, Purpose, Successful, ClientApplication, InstallationLabel, SharedMode, + DepartmentId, SessionId, ApproverSessionId) + VALUES (@MfaActivityId, @UserId, @OccurredOnUtc, @Method, @Purpose, @Successful, @ClientApplication, @InstallationLabel, @SharedMode, + @DepartmentId, @SessionId, @ApproverSessionId)", + new + { + activity.MfaActivityId, + activity.UserId, + OccurredOnUtc = Timestamp(activity.OccurredOnUtc), + activity.Method, + activity.Purpose, + activity.Successful, + activity.ClientApplication, + activity.InstallationLabel, + activity.SharedMode, + activity.DepartmentId, + activity.SessionId, + activity.ApproverSessionId + }, cancellationToken: cancellationToken)); + } + + public async Task CountDeniedSinceAsync(string userId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_table} WHERE UserId = @UserId AND Successful = @False AND OccurredOnUtc >= @Since", + new { UserId = userId, Since = Timestamp(sinceUtc), False = false }, cancellationToken: cancellationToken)); + } + + public async Task> GetRecentAsync(string userId, DateTime sinceUtc, int take, CancellationToken cancellationToken = default) + { + var sql = _postgres + ? $"SELECT {Columns} FROM {_table} WHERE UserId = @UserId AND OccurredOnUtc >= @Since ORDER BY OccurredOnUtc DESC LIMIT @Take" + : $"SELECT TOP (@Take) {Columns} FROM {_table} WHERE UserId = @UserId AND OccurredOnUtc >= @Since ORDER BY OccurredOnUtc DESC"; + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return (await connection.QueryAsync(new CommandDefinition(sql, + new { UserId = userId, Since = Timestamp(sinceUtc), Take = Math.Max(1, take) }, cancellationToken: cancellationToken))).ToList(); + } + + public async Task GetAsync(string mfaActivityId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE MfaActivityId = @Id", new { Id = mfaActivityId }, cancellationToken: cancellationToken)); + } + + public async Task TryMarkReportedAsync(string mfaActivityId, string userId, DateTime reportedOnUtc, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET ReportedOnUtc = @Now WHERE MfaActivityId = @Id AND UserId = @UserId AND ReportedOnUtc IS NULL", + new { Id = mfaActivityId, UserId = userId, Now = Timestamp(reportedOnUtc) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task PurgeBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE OccurredOnUtc < @Cutoff", new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + public async Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE UserId = @UserId", new { UserId = userId }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/MfaApprovalRequestRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/MfaApprovalRequestRepository.cs new file mode 100644 index 000000000..41da2b02a --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/MfaApprovalRequestRepository.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Responder approval requests (plan section 5.6; workbook section 8.3). Every transition is one guarded UPDATE whose + /// success is exactly one row, on its own connection, and advances Version. The one-pending-per-user rule is a + /// filtered unique index absorbed by the insert statement itself. + /// + public sealed class MfaApprovalRequestRepository : IMfaApprovalRequestRepository + { + private const int Pending = (int)MfaApprovalRequestState.Pending; + private const int Approved = (int)MfaApprovalRequestState.Approved; + private const int Denied = (int)MfaApprovalRequestState.Denied; + private const int Expired = (int)MfaApprovalRequestState.Expired; + private const int Canceled = (int)MfaApprovalRequestState.Canceled; + private const int Consumed = (int)MfaApprovalRequestState.Consumed; + private const int TooManyAttempts = (int)MfaApprovalEndReason.TooManyAttempts; + + private const string Columns = @"MfaApprovalRequestId, UserId, RequesterKind, RequesterId, ClientApplication, InstallationLabel, SharedMode, + DepartmentId, Purpose, Operation, LockVersion, AuthenticationGeneration, MatchNumberHash, OriginRegion, State, Version, Attempts, + MaxAttempts, CreatedOnUtc, ExpiresOnUtc, DecidedOnUtc, ConsumedOnUtc, EndReason, ApproverSessionId, ApproverPasskeyId"; + + private const string InsertValues = @"@MfaApprovalRequestId, @UserId, @RequesterKind, @RequesterId, @ClientApplication, @InstallationLabel, + @SharedMode, @DepartmentId, @Purpose, @Operation, @LockVersion, @AuthenticationGeneration, @MatchNumberHash, @OriginRegion, 0, 1, 0, + @MaxAttempts, @CreatedOnUtc, @ExpiresOnUtc, NULL, NULL, NULL, NULL, NULL"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public MfaApprovalRequestRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".mfaapprovalrequests" : ".[MfaApprovalRequests]"); + } + + public async Task TryInsertPendingAsync(MfaApprovalRequest request, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + + // A pending row past its expiry must not hold the one-pending slot. + await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET State = {Expired}, Version = Version + 1 WHERE UserId = @UserId AND State = {Pending} AND ExpiresOnUtc <= @Now", + new { request.UserId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + + var sql = _postgres + ? $@"INSERT INTO {_table} ({Columns}) VALUES ({InsertValues}) + ON CONFLICT (userid) WHERE state = {Pending} DO NOTHING" + : $@"MERGE {_table} WITH (HOLDLOCK) AS target + USING (SELECT @UserId AS UserId) AS source + ON target.UserId = source.UserId AND target.State = {Pending} + WHEN NOT MATCHED THEN + INSERT ({Columns}) VALUES ({InsertValues});"; + + return await connection.ExecuteAsync(new CommandDefinition(sql, new + { + request.MfaApprovalRequestId, + request.UserId, + request.RequesterKind, + request.RequesterId, + request.ClientApplication, + request.InstallationLabel, + request.SharedMode, + request.DepartmentId, + request.Purpose, + request.Operation, + request.LockVersion, + request.AuthenticationGeneration, + request.MatchNumberHash, + request.OriginRegion, + request.MaxAttempts, + CreatedOnUtc = Timestamp(request.CreatedOnUtc), + ExpiresOnUtc = Timestamp(request.ExpiresOnUtc) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task GetAsync(string approvalRequestId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE MfaApprovalRequestId = @Id", + new { Id = approvalRequestId }, cancellationToken: cancellationToken)); + } + + public async Task GetPendingForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QueryFirstOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE UserId = @UserId AND State = {Pending} AND ExpiresOnUtc > @Now ORDER BY CreatedOnUtc DESC", + new { UserId = userId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task> GetRecentForUserAsync(string userId, int take, CancellationToken cancellationToken = default) + { + var sql = _postgres + ? $"SELECT {Columns} FROM {_table} WHERE UserId = @UserId ORDER BY CreatedOnUtc DESC LIMIT @Take" + : $"SELECT TOP (@Take) {Columns} FROM {_table} WHERE UserId = @UserId ORDER BY CreatedOnUtc DESC"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return (await connection.QueryAsync(new CommandDefinition(sql, new { UserId = userId, Take = Math.Max(1, take) }, + cancellationToken: cancellationToken))).ToList(); + } + + public async Task CountCreatedSinceAsync(string userId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_table} WHERE UserId = @UserId AND CreatedOnUtc > @Since", + new { UserId = userId, Since = Timestamp(sinceUtc) }, cancellationToken: cancellationToken)); + } + + public async Task RecordWrongNumberAsync(string approvalRequestId, DateTime utcNow, + CancellationToken cancellationToken = default) + { + // Both engines evaluate every SET expression against the pre-update row, so the limit check sees the old count. + var set = $@"Attempts = Attempts + 1, Version = Version + 1, + State = CASE WHEN Attempts + 1 >= MaxAttempts THEN {Denied} ELSE State END, + EndReason = CASE WHEN Attempts + 1 >= MaxAttempts THEN {TooManyAttempts} ELSE EndReason END, + DecidedOnUtc = CASE WHEN Attempts + 1 >= MaxAttempts THEN @Now ELSE DecidedOnUtc END"; + var where = $"MfaApprovalRequestId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now"; + var sql = _postgres + ? $"UPDATE {_table} SET {set} WHERE {where} RETURNING State" + : $"UPDATE {_table} SET {set} OUTPUT INSERTED.State WHERE {where}"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + var state = await connection.QueryFirstOrDefaultAsync(new CommandDefinition(sql, new { Id = approvalRequestId, Now = Timestamp(utcNow) }, + cancellationToken: cancellationToken)); + return state == null ? null : (MfaApprovalRequestState)state.Value; + } + + public async Task TryApproveAsync(string approvalRequestId, string approverSessionId, string approverPasskeyId, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Approved}, Version = Version + 1, DecidedOnUtc = @Now, ApproverSessionId = @SessionId, + ApproverPasskeyId = @PasskeyId + WHERE MfaApprovalRequestId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now AND Attempts < MaxAttempts", + new { Id = approvalRequestId, SessionId = approverSessionId, PasskeyId = approverPasskeyId, Now = Timestamp(utcNow) }, + cancellationToken: cancellationToken)) == 1; + } + + public async Task TryDenyAsync(string approvalRequestId, MfaApprovalEndReason reason, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Denied}, Version = Version + 1, DecidedOnUtc = @Now, EndReason = @Reason + WHERE MfaApprovalRequestId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now", + new { Id = approvalRequestId, Reason = (int)reason, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryCancelAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Canceled}, Version = Version + 1, DecidedOnUtc = @Now, EndReason = @Reason + WHERE MfaApprovalRequestId = @Id AND State = {Pending} AND RequesterKind = @Kind AND RequesterId = @RequesterId", + new + { + Id = approvalRequestId, + Kind = (int)requesterKind, + RequesterId = requesterId, + Reason = (int)MfaApprovalEndReason.CanceledByRequester, + Now = Timestamp(utcNow) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryConsumeAsync(string approvalRequestId, MfaApprovalRequesterKind requesterKind, string requesterId, DateTime utcNow, + TimeSpan graceAfterExpiry, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Consumed}, Version = Version + 1, ConsumedOnUtc = @Now + WHERE MfaApprovalRequestId = @Id AND State = {Approved} AND RequesterKind = @Kind AND RequesterId = @RequesterId + AND ExpiresOnUtc > @Cutoff", + new + { + Id = approvalRequestId, + Kind = (int)requesterKind, + RequesterId = requesterId, + Now = Timestamp(utcNow), + Cutoff = Timestamp(utcNow - graceAfterExpiry) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task CancelPendingForUserAsync(string userId, MfaApprovalEndReason reason, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Canceled}, Version = Version + 1, DecidedOnUtc = @Now, EndReason = @Reason + WHERE UserId = @UserId AND State = {Pending}", + new { UserId = userId, Reason = (int)reason, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task PurgeCreatedBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE CreatedOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/MfaLoginTransactionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/MfaLoginTransactionRepository.cs new file mode 100644 index 000000000..1eb2e38a6 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/MfaLoginTransactionRepository.cs @@ -0,0 +1,149 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Restricted login MFA transactions (workbook section 8.3): every state change is one guarded UPDATE whose success is + /// exactly one row, on its own connection, so a transaction completes once and its completion code redeems once + /// however many nodes race for it. + /// + public sealed class MfaLoginTransactionRepository : IMfaLoginTransactionRepository + { + private const int Pending = (int)MfaLoginTransactionState.Pending; + private const int Completed = (int)MfaLoginTransactionState.Completed; + private const int Redeemed = (int)MfaLoginTransactionState.Redeemed; + private const int Exhausted = (int)MfaLoginTransactionState.Exhausted; + + private const string Columns = @"MfaLoginTransactionId, SecretHash, UserId, DepartmentId, ClientApplication, ClientId, FirstFactorMethod, + FirstFactorVerifiedOnUtc, DepartmentSsoConfigId, AuthenticationGeneration, MfaPolicyVersion, Scopes, CreatedOnUtc, ExpiresOnUtc, + Attempts, MaxAttempts, State, CompletionMethod, CompletionFactorReference, CompletionVerifiedOnUtc, IsRecovery, CompletionCodeHash, + CompletionExpiresOnUtc, RedeemedOnUtc, SharedMode, InstallationLabel"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public MfaLoginTransactionRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".mfalogintransactions" : ".[MfaLoginTransactions]"); + } + + public async Task InsertAsync(MfaLoginTransaction transaction, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (MfaLoginTransactionId, SecretHash, UserId, DepartmentId, ClientApplication, ClientId, FirstFactorMethod, + FirstFactorVerifiedOnUtc, DepartmentSsoConfigId, AuthenticationGeneration, MfaPolicyVersion, Scopes, CreatedOnUtc, ExpiresOnUtc, + Attempts, MaxAttempts, State, IsRecovery, SharedMode, InstallationLabel) + VALUES (@MfaLoginTransactionId, @SecretHash, @UserId, @DepartmentId, @ClientApplication, @ClientId, @FirstFactorMethod, + @FirstFactorVerifiedOnUtc, @DepartmentSsoConfigId, @AuthenticationGeneration, @MfaPolicyVersion, @Scopes, @CreatedOnUtc, @ExpiresOnUtc, + 0, @MaxAttempts, {Pending}, @False, @SharedMode, @InstallationLabel)", + new + { + transaction.MfaLoginTransactionId, + transaction.SecretHash, + transaction.UserId, + transaction.DepartmentId, + transaction.ClientApplication, + transaction.ClientId, + transaction.FirstFactorMethod, + FirstFactorVerifiedOnUtc = Timestamp(transaction.FirstFactorVerifiedOnUtc), + transaction.DepartmentSsoConfigId, + transaction.AuthenticationGeneration, + transaction.MfaPolicyVersion, + transaction.Scopes, + CreatedOnUtc = Timestamp(transaction.CreatedOnUtc), + ExpiresOnUtc = Timestamp(transaction.ExpiresOnUtc), + transaction.MaxAttempts, + False = false, + transaction.SharedMode, + transaction.InstallationLabel + }, cancellationToken: cancellationToken)); + } + + public async Task GetBySecretHashAsync(byte[] secretHash, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE SecretHash = @Hash", + new { Hash = secretHash }, cancellationToken: cancellationToken)); + } + + public async Task RecordFailedAttemptAsync(string transactionId, CancellationToken cancellationToken = default) + { + // Both engines evaluate every SET expression against the pre-update row, so the limit check sees the old count. + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET Attempts = Attempts + 1, + State = CASE WHEN Attempts + 1 >= MaxAttempts THEN {Exhausted} ELSE State END + WHERE MfaLoginTransactionId = @Id AND State = {Pending}", + new { Id = transactionId }, cancellationToken: cancellationToken)); + } + + public async Task TryCompleteAsync(string transactionId, int? method, string factorReference, DateTime? verifiedOnUtc, bool isRecovery, + byte[] completionCodeHash, DateTime completionExpiresOnUtc, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Completed}, CompletionMethod = @Method, CompletionFactorReference = @FactorReference, + CompletionVerifiedOnUtc = @VerifiedOn, IsRecovery = @IsRecovery, CompletionCodeHash = @CodeHash, CompletionExpiresOnUtc = @CodeExpires + WHERE MfaLoginTransactionId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now AND Attempts < MaxAttempts", + new + { + Id = transactionId, + Method = method, + FactorReference = factorReference, + VerifiedOn = verifiedOnUtc == null ? (DateTime?)null : Timestamp(verifiedOnUtc.Value), + IsRecovery = isRecovery, + CodeHash = completionCodeHash, + CodeExpires = Timestamp(completionExpiresOnUtc), + Now = Timestamp(utcNow) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryRedeemAsync(string transactionId, byte[] completionCodeHash, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Redeemed}, RedeemedOnUtc = @Now + WHERE MfaLoginTransactionId = @Id AND State = {Completed} AND CompletionCodeHash = @CodeHash AND CompletionExpiresOnUtc > @Now", + new { Id = transactionId, CodeHash = completionCodeHash, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryAbandonAsync(string transactionId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET State = {Exhausted} WHERE MfaLoginTransactionId = @Id AND State IN ({Pending}, {Completed})", + new { Id = transactionId }, cancellationToken: cancellationToken)) == 1; + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index f8225a5d9..d10db738f 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -364,6 +364,18 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Passkey plan Phase 1: one-time TOTP steps and hashed single-use recovery codes (compare-and-set, own connections). + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); // Protected Workflows (ADP push model): per-workflow releases and the per-department disclosure hash chain. diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index 7bf7981f0..cc6c7542e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -361,6 +361,10 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Unified Search (M0208), mirroring DataModule: services that refresh a search projection resolve these. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/SecurityNoticeRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SecurityNoticeRepository.cs new file mode 100644 index 000000000..326f410ed --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/SecurityNoticeRepository.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// The user-level security notice outbox (plan section 6.4). Claims take a lease in one guarded statement (PostgreSQL + /// FOR UPDATE SKIP LOCKED, SQL Server UPDLOCK, READPAST), and only the lease holder records a result. + /// + public sealed class SecurityNoticeRepository : ISecurityNoticeRepository + { + private const int Pending = (int)SecurityNoticeState.Pending; + private const int Sent = (int)SecurityNoticeState.Sent; + private const int Failed = (int)SecurityNoticeState.Failed; + + private const string Columns = @"SecurityNoticeId, UserId, Kind, OccurredOnUtc, ClientApplication, InstallationLabel, Region, State, Attempts, + NextAttemptOnUtc, LeaseOwner, LeaseUntilUtc, SentOnUtc, LastFailure, CreatedOnUtc"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public SecurityNoticeRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".securitynotices" : ".[SecurityNotices]"); + } + + public async Task InsertAsync(SecurityNotice notice, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (SecurityNoticeId, UserId, Kind, OccurredOnUtc, ClientApplication, InstallationLabel, Region, State, Attempts, + NextAttemptOnUtc, CreatedOnUtc) + VALUES (@SecurityNoticeId, @UserId, @Kind, @OccurredOnUtc, @ClientApplication, @InstallationLabel, @Region, {Pending}, 0, + @NextAttemptOnUtc, @CreatedOnUtc)", + new + { + notice.SecurityNoticeId, + notice.UserId, + notice.Kind, + OccurredOnUtc = Timestamp(notice.OccurredOnUtc), + notice.ClientApplication, + notice.InstallationLabel, + notice.Region, + NextAttemptOnUtc = Timestamp(notice.NextAttemptOnUtc), + CreatedOnUtc = Timestamp(notice.CreatedOnUtc) + }, cancellationToken: cancellationToken)); + } + + public async Task GetAsync(string securityNoticeId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE SecurityNoticeId = @Id", new { Id = securityNoticeId }, cancellationToken: cancellationToken)); + } + + public async Task TryClaimAsync(string securityNoticeId, string owner, DateTime utcNow, DateTime leaseUntilUtc, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET LeaseOwner = @Owner, LeaseUntilUtc = @Until + WHERE SecurityNoticeId = @Id AND State = {Pending} AND NextAttemptOnUtc <= @Now AND (LeaseUntilUtc IS NULL OR LeaseUntilUtc < @Now)", + new { Id = securityNoticeId, Owner = owner, Now = Timestamp(utcNow), Until = Timestamp(leaseUntilUtc) }, + cancellationToken: cancellationToken)) == 1; + } + + public async Task> ClaimDueAsync(string owner, DateTime utcNow, DateTime leaseUntilUtc, int batchSize, + CancellationToken cancellationToken = default) + { + var due = $"State = {Pending} AND NextAttemptOnUtc <= @Now AND (LeaseUntilUtc IS NULL OR LeaseUntilUtc < @Now)"; + var sql = _postgres + ? $@"UPDATE {_table} SET LeaseOwner = @Owner, LeaseUntilUtc = @Until + WHERE SecurityNoticeId IN (SELECT SecurityNoticeId FROM {_table} WHERE {due} ORDER BY NextAttemptOnUtc LIMIT @Batch FOR UPDATE SKIP LOCKED) + RETURNING {Columns}" + : $@"WITH due AS (SELECT TOP (@Batch) * FROM {_table} WITH (UPDLOCK, READPAST, ROWLOCK) WHERE {due} ORDER BY NextAttemptOnUtc) + UPDATE due SET LeaseOwner = @Owner, LeaseUntilUtc = @Until + OUTPUT INSERTED.SecurityNoticeId, INSERTED.UserId, INSERTED.Kind, INSERTED.OccurredOnUtc, INSERTED.ClientApplication, + INSERTED.InstallationLabel, INSERTED.Region, INSERTED.State, INSERTED.Attempts, INSERTED.NextAttemptOnUtc, INSERTED.LeaseOwner, + INSERTED.LeaseUntilUtc, INSERTED.SentOnUtc, INSERTED.LastFailure, INSERTED.CreatedOnUtc;"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return (await connection.QueryAsync(new CommandDefinition(sql, + new { Owner = owner, Now = Timestamp(utcNow), Until = Timestamp(leaseUntilUtc), Batch = Math.Max(1, batchSize) }, + cancellationToken: cancellationToken))).ToList(); + } + + public async Task MarkSentAsync(string securityNoticeId, string owner, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Sent}, SentOnUtc = @Now, Attempts = Attempts + 1, LeaseOwner = NULL, LeaseUntilUtc = NULL, LastFailure = NULL + WHERE SecurityNoticeId = @Id AND State = {Pending} AND LeaseOwner = @Owner", + new { Id = securityNoticeId, Owner = owner, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task MarkRetryAsync(string securityNoticeId, string owner, DateTime nextAttemptOnUtc, string failure, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET Attempts = Attempts + 1, NextAttemptOnUtc = @Next, LastFailure = @Failure, LeaseOwner = NULL, LeaseUntilUtc = NULL + WHERE SecurityNoticeId = @Id AND State = {Pending} AND LeaseOwner = @Owner", + new { Id = securityNoticeId, Owner = owner, Next = Timestamp(nextAttemptOnUtc), Failure = Limit(failure) }, + cancellationToken: cancellationToken)) == 1; + } + + public async Task MarkFailedAsync(string securityNoticeId, string owner, string failure, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Failed}, Attempts = Attempts + 1, LastFailure = @Failure, LeaseOwner = NULL, LeaseUntilUtc = NULL + WHERE SecurityNoticeId = @Id AND State = {Pending} AND LeaseOwner = @Owner", + new { Id = securityNoticeId, Owner = owner, Failure = Limit(failure) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task ExistsSinceAsync(string userId, SecurityNoticeKind kind, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_table} WHERE UserId = @UserId AND Kind = @Kind AND CreatedOnUtc > @Since AND State <> {Failed}", + new { UserId = userId, Kind = (int)kind, Since = Timestamp(sinceUtc) }, cancellationToken: cancellationToken)) > 0; + } + + public async Task PurgeFinishedBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE State <> {Pending} AND CreatedOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + public async Task DeleteForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE UserId = @UserId", new { UserId = userId }, cancellationToken: cancellationToken)); + } + + private static string Limit(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Length <= 64 ? value : value[..64]; + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/SsoLoginTransactionRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/SsoLoginTransactionRepository.cs new file mode 100644 index 000000000..c179905fc --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/SsoLoginTransactionRepository.cs @@ -0,0 +1,150 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Brokered SSO transactions (workbook section 8.3): every state change is one guarded UPDATE whose success is exactly + /// one row, on its own connection, so an IdP result is accepted once and its one-time code redeemed once. + /// + public sealed class SsoLoginTransactionRepository : ISsoLoginTransactionRepository + { + private const int Pending = (int)SsoLoginTransactionState.Pending; + private const int Authenticated = (int)SsoLoginTransactionState.Authenticated; + private const int Redeemed = (int)SsoLoginTransactionState.Redeemed; + private const int Failed = (int)SsoLoginTransactionState.Failed; + + private const string Columns = @"SsoLoginTransactionId, StateHash, Purpose, DepartmentId, DepartmentSsoConfigId, ProviderType, ClientApplication, + Platform, ReturnTarget, ClientState, CodeChallenge, NonceHash, EncryptedIdpCodeVerifier, SamlRequestId, SessionId, ExpectedUserId, + AuthenticationGeneration, CreatedOnUtc, ExpiresOnUtc, State, UserId, AuthenticatedOnUtc, CodeHash, CodeExpiresOnUtc, RedeemedOnUtc, + FailureCode, Operation, LoginTransactionId, FederatedMappingVersion, FederatedMfaValue, SharedInstallation"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public SsoLoginTransactionRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".ssologintransactions" : ".[SsoLoginTransactions]"); + } + + public async Task InsertAsync(SsoLoginTransaction transaction, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (SsoLoginTransactionId, StateHash, Purpose, DepartmentId, DepartmentSsoConfigId, ProviderType, ClientApplication, + Platform, ReturnTarget, ClientState, CodeChallenge, NonceHash, EncryptedIdpCodeVerifier, SamlRequestId, SessionId, ExpectedUserId, + AuthenticationGeneration, CreatedOnUtc, ExpiresOnUtc, State, Operation, LoginTransactionId, FederatedMappingVersion, SharedInstallation) + VALUES (@SsoLoginTransactionId, @StateHash, @Purpose, @DepartmentId, @DepartmentSsoConfigId, @ProviderType, @ClientApplication, + @Platform, @ReturnTarget, @ClientState, @CodeChallenge, @NonceHash, @EncryptedIdpCodeVerifier, @SamlRequestId, @SessionId, @ExpectedUserId, + @AuthenticationGeneration, @CreatedOnUtc, @ExpiresOnUtc, {Pending}, @Operation, @LoginTransactionId, @FederatedMappingVersion, + @SharedInstallation)", + new + { + transaction.SsoLoginTransactionId, + transaction.StateHash, + transaction.Purpose, + transaction.DepartmentId, + transaction.DepartmentSsoConfigId, + transaction.ProviderType, + transaction.ClientApplication, + transaction.Platform, + transaction.ReturnTarget, + transaction.ClientState, + transaction.CodeChallenge, + transaction.NonceHash, + transaction.EncryptedIdpCodeVerifier, + transaction.SamlRequestId, + transaction.SessionId, + transaction.ExpectedUserId, + transaction.AuthenticationGeneration, + CreatedOnUtc = Timestamp(transaction.CreatedOnUtc), + ExpiresOnUtc = Timestamp(transaction.ExpiresOnUtc), + transaction.Operation, + transaction.LoginTransactionId, + transaction.FederatedMappingVersion, + transaction.SharedInstallation + }, cancellationToken: cancellationToken)); + } + + public async Task GetAsync(string transactionId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE SsoLoginTransactionId = @Id", + new { Id = transactionId }, cancellationToken: cancellationToken)); + } + + public async Task GetByStateHashAsync(byte[] stateHash, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE StateHash = @Hash", + new { Hash = stateHash }, cancellationToken: cancellationToken)); + } + + public async Task TryAuthenticateAsync(string transactionId, string userId, DateTime authenticatedOnUtc, string federatedMfaValue, + byte[] codeHash, DateTime codeExpiresOnUtc, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Authenticated}, UserId = @UserId, AuthenticatedOnUtc = @AuthenticatedOn, CodeHash = @CodeHash, + CodeExpiresOnUtc = @CodeExpires, FederatedMfaValue = @FederatedMfaValue + WHERE SsoLoginTransactionId = @Id AND State = {Pending} AND ExpiresOnUtc > @Now", + new + { + Id = transactionId, + UserId = userId, + FederatedMfaValue = federatedMfaValue, + AuthenticatedOn = Timestamp(authenticatedOnUtc), + CodeHash = codeHash, + CodeExpires = Timestamp(codeExpiresOnUtc), + Now = Timestamp(utcNow) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryFailAsync(string transactionId, string failureCode, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET State = {Failed}, FailureCode = @FailureCode WHERE SsoLoginTransactionId = @Id AND State = {Pending}", + new { Id = transactionId, FailureCode = failureCode }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryRedeemAsync(string transactionId, byte[] codeHash, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET State = {Redeemed}, RedeemedOnUtc = @Now + WHERE SsoLoginTransactionId = @Id AND State = {Authenticated} AND CodeHash = @CodeHash AND CodeExpiresOnUtc > @Now", + new { Id = transactionId, CodeHash = codeHash, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedMigrator.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedMigrator.cs new file mode 100644 index 000000000..e5c8ba4f5 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedMigrator.cs @@ -0,0 +1,104 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + public enum AuthenticatorSeedMigration + { + /// Encrypt plaintext seeds, and re-encrypt seeds under a retired key with the active one. + Encrypt = 1, + + /// Write every seed back as plaintext, before rolling back below the build that encrypts them. + Decrypt = 2 + } + + public sealed class AuthenticatorSeedMigrationResult + { + public int Scanned { get; set; } + public int Rewritten { get; set; } + + /// Already in the requested form. + public int Current { get; set; } + + /// Encrypted under a key that is not configured, or damaged. Left as is and reported. + public int Unreadable { get; set; } + + /// Replaced by the user while this ran; the new value was written in the current form. + public int ChangedMeanwhile { get; set; } + } + + /// + /// Rewrites every stored authenticator seed, active and staged, in pages, with the same guarded replace the lazy path + /// uses (slice 14). Encrypting needs the gate on, which is the operator's statement that every host reads encrypted + /// seeds; decrypting needs it off, or reads would re-encrypt behind it. Safe to run again: finished rows are skipped. + /// + public static class AuthenticatorSeedMigrator + { + public static async Task RunAsync(IIdentityUserRepository users, AuthenticatorSeedMigration direction, + int batchSize, CancellationToken cancellationToken = default) + { + if (direction == AuthenticatorSeedMigration.Encrypt && !TwoFactorConfig.AuthenticatorSeedEncryptionEnabled) + throw new InvalidOperationException("Turn TwoFactorConfig.AuthenticatorSeedEncryptionEnabled on, on every host, before encrypting seeds."); + if (direction == AuthenticatorSeedMigration.Decrypt && TwoFactorConfig.AuthenticatorSeedEncryptionEnabled) + throw new InvalidOperationException("Turn TwoFactorConfig.AuthenticatorSeedEncryptionEnabled off, on every host, before decrypting seeds."); + if (direction == AuthenticatorSeedMigration.Encrypt && AuthenticatorSeedProtector.Readiness().Count > 0) + throw new InvalidOperationException("The authenticator seed keys are not usable: " + string.Join(" ", AuthenticatorSeedProtector.Readiness())); + + var result = new AuthenticatorSeedMigrationResult(); + await RunAsync(users, direction, AuthenticatorSeedUse.Active, AuthenticatorSeedProtector.ActiveLoginProvider, + AuthenticatorSeedProtector.ActiveTokenName, Math.Max(1, batchSize), result, cancellationToken); + await RunAsync(users, direction, AuthenticatorSeedUse.Staged, StagedAuthenticatorKey.LoginProvider, StagedAuthenticatorKey.TokenName, + Math.Max(1, batchSize), result, cancellationToken); + return result; + } + + private static async Task RunAsync(IIdentityUserRepository users, AuthenticatorSeedMigration direction, AuthenticatorSeedUse use, + string loginProvider, string name, int batchSize, AuthenticatorSeedMigrationResult result, CancellationToken cancellationToken) + { + string after = null; + while (true) + { + var page = await users.GetTokensPageAsync(loginProvider, name, after, batchSize, cancellationToken); + foreach (var row in page) + { + cancellationToken.ThrowIfCancellationRequested(); + result.Scanned++; + after = row.UserId; + if (string.IsNullOrEmpty(row.Value)) + { + result.Current++; + continue; + } + + var read = AuthenticatorSeedProtector.Unprotect(row.UserId, use, row.Value); + if (read.Failed) + { + result.Unreadable++; + continue; + } + + var target = direction == AuthenticatorSeedMigration.Encrypt + ? read.IsPlaintext || read.NeedsRewrap ? AuthenticatorSeedProtector.Protect(row.UserId, use, read.Value) : null + : read.IsPlaintext ? null : read.Value; + if (target == null) + { + result.Current++; + continue; + } + + if (await users.TryReplaceTokenAsync(row.UserId, loginProvider, name, row.Value, target, cancellationToken)) + result.Rewritten++; + else + result.ChangedMeanwhile++; + } + + if (page.Count < batchSize) + return; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedProtector.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedProtector.cs new file mode 100644 index 000000000..c97e64c1e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSeedProtector.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Resgrid.Config; +using Resgrid.Model.Security; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + /// Which authenticator seed a token holds; each is bound to its own use. + public enum AuthenticatorSeedUse + { + /// The account's working authenticator key. + Active = 1, + + /// A replacement staged for setup or replacement, with its staging time (). + Staged = 2 + } + + /// What reading a stored seed found. + public readonly struct AuthenticatorSeedRead + { + public string Value { get; init; } + + /// Stored before encryption (or while the gate was off); the value is the plaintext as stored. + public bool IsPlaintext { get; init; } + + /// Encrypted with a key that is no longer the active one. + public bool NeedsRewrap { get; init; } + + /// Encrypted but unreadable: an unknown key, another user's or use's value, or tampering. Treat as no seed. + public bool Failed { get; init; } + } + + /// + /// TOTP seeds at rest (passkey workbook section 12, slice 14): AES-256-GCM under a key ring, stored as + /// tseed1:{keyId}:{base64(nonce | tag | ciphertext)}. The user id and the seed's use are the associated data, + /// so a value copied to another account, or from a staged key to the active one, does not decrypt. Plaintext from + /// before this build still reads, and is re-encrypted as it is read once the gate is on. + /// + public static class AuthenticatorSeedProtector + { + public const string Prefix = "tseed1:"; + + /// Where the active seed lives in AspNetUserTokens (ASP.NET Identity's own names). + public const string ActiveLoginProvider = "[AspNetUserStore]"; + public const string ActiveTokenName = "AuthenticatorKey"; + + /// The key derived from SecurityConfig.EncryptionKey, always readable while that key is configured. + public const string MasterDerivedKeyId = "m1"; + + private const int NonceSize = 12; + private const int TagSize = 16; + private static readonly Regex KeyId = new("^[A-Za-z0-9]{1,16}$", RegexOptions.CultureInvariant); + + public static bool IsProtected(string stored) => stored != null && stored.StartsWith(Prefix, StringComparison.Ordinal); + + /// The key id new seeds are written with. + public static string ActiveKeyId => + string.IsNullOrWhiteSpace(TwoFactorConfig.AuthenticatorSeedActiveKeyId) ? MasterDerivedKeyId : TwoFactorConfig.AuthenticatorSeedActiveKeyId.Trim(); + + /// Encrypts a seed with the active key. Fails closed (throws) when the key configuration is not usable. + public static string Protect(string userId, AuthenticatorSeedUse use, string seed) + { + if (string.IsNullOrWhiteSpace(userId)) + throw new ArgumentException("A user id is required.", nameof(userId)); + if (seed == null) + throw new ArgumentNullException(nameof(seed)); + + var keyId = ActiveKeyId; + var key = KeyFor(keyId) ?? throw new InvalidOperationException("The authenticator seed key configuration is not usable; see the readiness report."); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var plain = Encoding.UTF8.GetBytes(seed); + var cipher = new byte[plain.Length]; + var tag = new byte[TagSize]; + using (var aes = new AesGcm(key, TagSize)) + aes.Encrypt(nonce, plain, cipher, tag, AssociatedData(userId, use, keyId)); + + var payload = new byte[NonceSize + TagSize + cipher.Length]; + Buffer.BlockCopy(nonce, 0, payload, 0, NonceSize); + Buffer.BlockCopy(tag, 0, payload, NonceSize, TagSize); + Buffer.BlockCopy(cipher, 0, payload, NonceSize + TagSize, cipher.Length); + return Prefix + keyId + ":" + Convert.ToBase64String(payload); + } + + /// Reads a stored seed. Never throws for bad input: an unreadable value comes back . + public static AuthenticatorSeedRead Unprotect(string userId, AuthenticatorSeedUse use, string stored) + { + if (stored == null) + return default; + if (!IsProtected(stored)) + return new AuthenticatorSeedRead { Value = stored, IsPlaintext = true }; + + var body = stored.Substring(Prefix.Length); + var separator = body.IndexOf(':'); + if (separator <= 0 || string.IsNullOrWhiteSpace(userId)) + return new AuthenticatorSeedRead { Failed = true }; + + var keyId = body.Substring(0, separator); + var key = KeyFor(keyId); + if (key == null) + return new AuthenticatorSeedRead { Failed = true }; + + try + { + var payload = Convert.FromBase64String(body.Substring(separator + 1)); + if (payload.Length < NonceSize + TagSize) + return new AuthenticatorSeedRead { Failed = true }; + + var cipher = new byte[payload.Length - NonceSize - TagSize]; + var plain = new byte[cipher.Length]; + Buffer.BlockCopy(payload, NonceSize + TagSize, cipher, 0, cipher.Length); + using (var aes = new AesGcm(key, TagSize)) + aes.Decrypt(payload.AsSpan(0, NonceSize), cipher, payload.AsSpan(NonceSize, TagSize), plain, AssociatedData(userId, use, keyId)); + + return new AuthenticatorSeedRead + { + Value = Encoding.UTF8.GetString(plain), + NeedsRewrap = !string.Equals(keyId, ActiveKeyId, StringComparison.Ordinal) + }; + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return new AuthenticatorSeedRead { Failed = true }; + } + } + + /// + /// Value-free problems with the key configuration: a malformed ring, a key that is not 32 bytes, an active key that is + /// not in the ring, or no master key for the derived one. Empty when seeds can be written. + /// + public static IReadOnlyList Readiness() + { + var problems = new List(); + var (ring, ringProblems) = ParseRing(TwoFactorConfig.AuthenticatorSeedKeyRing); + problems.AddRange(ringProblems); + var active = ActiveKeyId; + if (!KeyId.IsMatch(active)) + problems.Add("The active authenticator seed key id is not valid."); + else if (!ring.ContainsKey(active) && !(active == MasterDerivedKeyId && HasMasterKey)) + problems.Add(active == MasterDerivedKeyId + ? "SecurityConfig.EncryptionKey is required for the derived authenticator seed key." + : "The active authenticator seed key is not in the key ring."); + return problems; + } + + /// Logs the readiness at startup: loud only when the gate is on but seeds cannot be written. + public static void ReportReadiness() + { + var problems = Readiness(); + if (!TwoFactorConfig.AuthenticatorSeedEncryptionEnabled) + return; + if (problems.Count == 0) + Framework.Logging.LogInfo($"Authenticator seed encryption is on with key {ActiveKeyId}."); + else + Framework.Logging.LogError("Authenticator seed encryption is on but its keys are not usable, so new authenticators cannot be set up: " + + string.Join(" ", problems)); + } + + private static bool HasMasterKey => !string.IsNullOrWhiteSpace(SecurityConfig.EncryptionKey); + + private static byte[] KeyFor(string keyId) + { + if (string.IsNullOrWhiteSpace(keyId) || !KeyId.IsMatch(keyId)) + return null; + + var (ring, _) = ParseRing(TwoFactorConfig.AuthenticatorSeedKeyRing); + if (ring.TryGetValue(keyId, out var key)) + return key; + + // The master-derived key stays readable while the master key is configured, so seeds written before a ring existed + // keep working until they are re-encrypted. + if (keyId == MasterDerivedKeyId && HasMasterKey) + return HKDF.DeriveKey(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(SecurityConfig.EncryptionKey), 32, + Encoding.UTF8.GetBytes(SecurityConfig.EncryptionSaltValue ?? string.Empty), Encoding.UTF8.GetBytes("Resgrid.AuthenticatorSeed.m1")); + + return null; + } + + private static (Dictionary Ring, List Problems) ParseRing(string configured) + { + var ring = new Dictionary(StringComparer.Ordinal); + var problems = new List(); + foreach (var entry in (configured ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separator = entry.IndexOf('='); + var id = separator > 0 ? entry.Substring(0, separator).Trim() : null; + if (id == null || !KeyId.IsMatch(id)) + { + problems.Add("An authenticator seed key ring entry has no valid key id."); + continue; + } + + byte[] key; + try + { + key = Convert.FromBase64String(entry.Substring(separator + 1).Trim()); + } + catch (FormatException) + { + problems.Add($"Authenticator seed key {id} is not base64."); + continue; + } + + if (key.Length != 32) + problems.Add($"Authenticator seed key {id} is not 32 bytes."); + else if (!ring.TryAdd(id, key)) + problems.Add($"Authenticator seed key {id} appears twice."); + } + + return (ring, problems); + } + + private static byte[] AssociatedData(string userId, AuthenticatorSeedUse use, string keyId) => + Encoding.UTF8.GetBytes($"Resgrid.AuthenticatorSeed|{(use == AuthenticatorSeedUse.Staged ? "staged" : "active")}|{keyId}|{userId.Trim().ToLowerInvariant()}"); + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSetup.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSetup.cs new file mode 100644 index 000000000..275fce157 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/AuthenticatorSetup.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + /// + /// Authenticator (TOTP) setup and replacement, shared by the API and Web sign-in, setup and recovery (passkey plan section 6.2; + /// Web's TwoFactorController account page keeps the same steps and order): a new key is staged apart from the active one, it + /// becomes active only after a code from it verifies (and that time step is spent), and replacing a factor retires everything + /// the old one authorized. + /// + public static class AuthenticatorSetup + { + /// Stages a new key for this user; returns it formatted for typing and as an otpauth:// URI. + public static async Task<(string SharedKey, string AuthenticatorUri)> StageAsync(UserManager userManager, IdentityUser user) + { + var key = userManager.GenerateNewAuthenticatorKey(); + await userManager.SetAuthenticationTokenAsync(user, StagedAuthenticatorKey.LoginProvider, StagedAuthenticatorKey.TokenName, + StagedAuthenticatorKey.Serialize(key, DateTime.UtcNow)); + return (FormatKey(key), await UriAsync(userManager, user, key)); + } + + /// The otpauth:// URI an authenticator app scans for . + public static async Task UriAsync(UserManager userManager, IdentityUser user, string key) + { + var issuer = UrlEncoder.Default.Encode(TwoFactorConfig.TotpIssuerName); + var account = UrlEncoder.Default.Encode(await userManager.GetEmailAsync(user) ?? user.UserName ?? ""); + return $"otpauth://totp/{issuer}:{account}?secret={key}&issuer={issuer}&digits=6"; + } + + /// The staged key while it is within its lifetime; null otherwise. + public static async Task GetStagedKeyAsync(UserManager userManager, IdentityUser user) => + StagedAuthenticatorKey.ReadUsableKey( + await userManager.GetAuthenticationTokenAsync(user, StagedAuthenticatorKey.LoginProvider, StagedAuthenticatorKey.TokenName), + DateTime.UtcNow, TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.StagedAuthenticatorLifetimeMinutes))); + + /// Whether comes from the staged key; the time step is spent either way it is used. + public static Task VerifyStagedCodeAsync(IUserMfaStateRepository mfaState, IdentityUser user, string stagedKey, string code, + CancellationToken cancellationToken) => + string.IsNullOrWhiteSpace(stagedKey) || string.IsNullOrWhiteSpace(code) + ? Task.FromResult(false) + : ResgridAuthenticatorTokenProvider.ValidateAndConsumeAsync(mfaState, user.Id, stagedKey, + code.Replace(" ", string.Empty).Replace("-", string.Empty), DateTime.UtcNow, cancellationToken); + + /// + /// Makes the staged key the active one and turns TOTP on, recording where it was set up (plan section 6.5): the app, the + /// installation label, and whether it was a shared installation. + /// + public static async Task PromoteAsync(UserManager userManager, IUserStore userStore, IUserMfaStateRepository mfaState, + IdentityUser user, string stagedKey, TotpEnrollmentContext context, CancellationToken cancellationToken) + { + if (userStore is not IUserAuthenticatorKeyStore keyStore) + throw new InvalidOperationException("The user store does not support authenticator keys."); + + await keyStore.SetAuthenticatorKeyAsync(user, stagedKey, cancellationToken); + await userManager.RemoveAuthenticationTokenAsync(user, StagedAuthenticatorKey.LoginProvider, StagedAuthenticatorKey.TokenName); + await mfaState.RecordTotpEnrollmentAsync(user.Id, DateTime.UtcNow, context, cancellationToken); + if (!await userManager.GetTwoFactorEnabledAsync(user)) + await userManager.SetTwoFactorEnabledAsync(user, true); + } + + public static async Task NewRecoveryCodesAsync(UserManager userManager, IdentityUser user) => + (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, TwoFactorConfig.DefaultRecoveryCodeCount)).ToArray(); + + /// + /// After a replacement: the old seed, remembered state (security stamp), every session and every piece of evidence + /// end, and fresh recovery codes replace the old ones. Returns the new codes, shown once. + /// + public static async Task RetireOldAuthorityAsync(UserManager userManager, IUserSessionService sessions, + IMfaEvidenceService evidence, IdentityUser user, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; + await userManager.UpdateSecurityStampAsync(user); + var codes = await NewRecoveryCodesAsync(userManager, user); + await sessions.RevokeAllAfterCredentialChangeAsync(user.Id, user.Id, UserSessionRevocationReason.MfaChanged, now, cancellationToken); + await evidence.RevokeForUserAsync(user.Id, cancellationToken); + return codes; + } + + /// The key in groups of four, lower case, for typing into an authenticator app. + public static string FormatKey(string key) + { + var result = new StringBuilder(); + for (var i = 0; i < key.Length; i += 4) + result.Append(key.AsSpan(i, Math.Min(4, key.Length - i))).Append(i + 4 < key.Length ? " " : ""); + return result.ToString().ToLowerInvariant(); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/IdentityUserStore.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/IdentityUserStore.cs index f4c3834b7..0a7d8c166 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Stores/IdentityUserStore.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/IdentityUserStore.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Identity; +using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model.Identity; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Connection; using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Security; using System; using System.Collections.Generic; using System.ComponentModel; @@ -36,14 +38,17 @@ public class IdentityUserStore : private readonly IUnitOfWork _unitOfWork; private readonly IConnectionProvider _connectionProvider; private readonly IIdentityUserRepository _userRepository; + private readonly IUserMfaStateRepository _mfaStateRepository; public IdentityUserStore(IConnectionProvider connProv, IIdentityUserRepository roleRepo, - IUnitOfWork uow) + IUnitOfWork uow, + IUserMfaStateRepository mfaStateRepository) { _userRepository = roleRepo; _connectionProvider = connProv; _unitOfWork = uow; + _mfaStateRepository = mfaStateRepository; } public Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) => CommitTransactionAsync(cancellationToken); @@ -463,16 +468,60 @@ public async Task GetTokenAsync(IdentityUser user, string loginProvider, { cancellationToken.ThrowIfCancellationRequested(); if (user == null) throw new ArgumentNullException(nameof(user)); - return await _userRepository.GetTokenAsync(user.Id, loginProvider, name); + var stored = await _userRepository.GetTokenAsync(user.Id, loginProvider, name); + var use = SeedUse(loginProvider, name); + return use == null || stored == null ? stored : await ReadSeedAsync(user.Id, loginProvider, name, use.Value, stored, cancellationToken); } public async Task SetTokenAsync(IdentityUser user, string loginProvider, string name, string value, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (user == null) throw new ArgumentNullException(nameof(user)); + var use = SeedUse(loginProvider, name); + if (use != null && value != null && TwoFactorConfig.AuthenticatorSeedEncryptionEnabled) + value = AuthenticatorSeedProtector.Protect(user.Id, use.Value, value); await _userRepository.SetTokenAsync(user.Id, loginProvider, name, value, cancellationToken); } + /// The authenticator seed tokens: the active key, and a replacement staged for setup (slice 14). + private static AuthenticatorSeedUse? SeedUse(string loginProvider, string name) => + loginProvider == AuthenticatorKeyLoginProvider && name == AuthenticatorKeyTokenName ? AuthenticatorSeedUse.Active + : loginProvider == StagedAuthenticatorKey.LoginProvider && name == StagedAuthenticatorKey.TokenName ? AuthenticatorSeedUse.Staged + : null; + + /// + /// A stored seed as the authenticator uses it. An unreadable one (unknown key, moved between accounts or uses, or + /// tampered with) reads as no seed, so the code check fails closed; the account still has its recovery codes. With the + /// gate on, a plaintext seed or one under a retired key is re-encrypted here, once, and only if nothing replaced it + /// meanwhile. + /// + private async Task ReadSeedAsync(string userId, string loginProvider, string name, AuthenticatorSeedUse use, string stored, + CancellationToken cancellationToken) + { + var read = AuthenticatorSeedProtector.Unprotect(userId, use, stored); + if (read.Failed) + { + Framework.Logging.LogError($"An authenticator seed could not be decrypted ({use}); the account's authenticator reads as not set up."); + return null; + } + + if (TwoFactorConfig.AuthenticatorSeedEncryptionEnabled && (read.IsPlaintext || read.NeedsRewrap)) + { + try + { + await _userRepository.TryReplaceTokenAsync(userId, loginProvider, name, stored, + AuthenticatorSeedProtector.Protect(userId, use, read.Value), cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The seed still works as read; the next read or the migration command tries again. + Framework.Logging.LogException(ex, "Authenticator seed re-encryption failed."); + } + } + + return read.Value; + } + public async Task RemoveTokenAsync(IdentityUser user, string loginProvider, string name, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -482,8 +531,8 @@ public async Task RemoveTokenAsync(IdentityUser user, string loginProvider, stri // ── IUserAuthenticatorKeyStore ───────────────────────────────────────────── - private const string AuthenticatorKeyLoginProvider = "[AspNetUserStore]"; - private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; + private const string AuthenticatorKeyLoginProvider = AuthenticatorSeedProtector.ActiveLoginProvider; + private const string AuthenticatorKeyTokenName = AuthenticatorSeedProtector.ActiveTokenName; private const string RecoveryCodeTokenName = "RecoveryCodes"; public Task SetAuthenticatorKeyAsync(IdentityUser user, string key, CancellationToken cancellationToken) @@ -493,29 +542,30 @@ public Task GetAuthenticatorKeyAsync(IdentityUser user, CancellationToke => GetTokenAsync(user, AuthenticatorKeyLoginProvider, AuthenticatorKeyTokenName, cancellationToken); // ── IUserTwoFactorRecoveryCodeStore ──────────────────────────────────────── + // Codes live as HMAC verifiers in UserRecoveryCodes (M0243), one row each, consumed by a single guarded UPDATE + // so a code redeemed concurrently on two nodes succeeds once. The pre-M0243 plaintext ";"-joined token is + // migrated on first use and then deleted. public async Task ReplaceCodesAsync(IdentityUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) { - var mergedCodes = string.Join(";", recoveryCodes); - await SetTokenAsync(user, AuthenticatorKeyLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (user == null) throw new ArgumentNullException(nameof(user)); + + var hashes = (recoveryCodes ?? Enumerable.Empty()) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => RecoveryCodeHasher.Hash(user.Id, code)) + .ToList(); + await _mfaStateRepository.ReplaceRecoveryCodesAsync(user.Id, hashes, RecoveryCodeHasher.CurrentVersion, DateTime.UtcNow, cancellationToken); } public async Task RedeemCodeAsync(IdentityUser user, string code, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (user == null) throw new ArgumentNullException(nameof(user)); + if (string.IsNullOrWhiteSpace(code)) return false; - var mergedCodes = await GetTokenAsync(user, AuthenticatorKeyLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? string.Empty; - var splitCodes = mergedCodes.Split(';'); - - if (splitCodes.Contains(code)) - { - var updatedCodes = splitCodes.Where(s => s != code); - await ReplaceCodesAsync(user, updatedCodes, cancellationToken); - return true; - } - - return false; + await MigrateLegacyRecoveryCodesAsync(user, cancellationToken); + return await _mfaStateRepository.TryRedeemRecoveryCodeAsync(user.Id, RecoveryCodeHasher.Hash(user.Id, code), DateTime.UtcNow, cancellationToken); } public async Task CountCodesAsync(IdentityUser user, CancellationToken cancellationToken) @@ -523,11 +573,24 @@ public async Task CountCodesAsync(IdentityUser user, CancellationToken canc cancellationToken.ThrowIfCancellationRequested(); if (user == null) throw new ArgumentNullException(nameof(user)); - var mergedCodes = await GetTokenAsync(user, AuthenticatorKeyLoginProvider, RecoveryCodeTokenName, cancellationToken) ?? string.Empty; - if (string.IsNullOrEmpty(mergedCodes)) - return 0; + await MigrateLegacyRecoveryCodesAsync(user, cancellationToken); + return await _mfaStateRepository.CountUnusedRecoveryCodesAsync(user.Id, cancellationToken); + } + + private async Task MigrateLegacyRecoveryCodesAsync(IdentityUser user, CancellationToken cancellationToken) + { + var legacy = await GetTokenAsync(user, AuthenticatorKeyLoginProvider, RecoveryCodeTokenName, cancellationToken); + if (legacy == null) + return; + + var hashes = legacy.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(code => RecoveryCodeHasher.Hash(user.Id, code)) + .ToList(); - return mergedCodes.Split(';').Length; + // Returns false when another request already migrated (or regenerated) this user's codes; either way the + // verifier rows are now authoritative. + await _mfaStateRepository.ImportLegacyRecoveryCodesAsync(user.Id, legacy, hashes, RecoveryCodeHasher.CurrentVersion, + DateTime.UtcNow, cancellationToken); } public Task GetTwoFactorEnabledAsync(IdentityUser user, CancellationToken cancellationToken) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/RecoveryCodeHasher.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/RecoveryCodeHasher.cs new file mode 100644 index 000000000..c82877ff2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/RecoveryCodeHasher.cs @@ -0,0 +1,49 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Resgrid.Config; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + /// + /// Recovery-code verifiers (plan section 6.2): HMAC-SHA256 keyed from the system master key, so a database copy alone + /// cannot brute-force the ~47-bit codes, and the hash is deterministic so redemption is a single guarded UPDATE. + /// + public static class RecoveryCodeHasher + { + /// Stored with every row; bump when the key derivation changes so old rows can be recognised. + public const int CurrentVersion = 1; + + private static readonly byte[] Info = Encoding.UTF8.GetBytes("Resgrid.RecoveryCodes.v1"); + + /// + /// Codes are compared case-insensitively and without spaces or hyphens, so "abcde-fghij" and "ABCDEFGHIJ" are the + /// same code. The user id is bound into the MAC input. + /// + public static byte[] Hash(string userId, string code) + { + if (string.IsNullOrWhiteSpace(userId)) + throw new ArgumentException("A user id is required.", nameof(userId)); + + var key = DeriveKey(); + var input = Encoding.UTF8.GetBytes(userId + ":" + Normalize(code)); + return HMACSHA256.HashData(key, input); + } + + public static string Normalize(string code) + => (code ?? string.Empty).Replace(" ", string.Empty).Replace("-", string.Empty).Trim().ToUpperInvariant(); + + // Fails closed: without the master key there is no verifier, so recovery codes can neither be issued nor redeemed. + private static byte[] DeriveKey() + { + if (string.IsNullOrWhiteSpace(SecurityConfig.EncryptionKey)) + throw new InvalidOperationException("SecurityConfig.EncryptionKey is required to issue or redeem recovery codes."); + + return HKDF.DeriveKey(HashAlgorithmName.SHA256, + Encoding.UTF8.GetBytes(SecurityConfig.EncryptionKey), + 32, + Encoding.UTF8.GetBytes(SecurityConfig.EncryptionSaltValue ?? string.Empty), + Info); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/ResgridAuthenticatorTokenProvider.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/ResgridAuthenticatorTokenProvider.cs new file mode 100644 index 000000000..694efc8c8 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/ResgridAuthenticatorTokenProvider.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Resgrid.Framework; +using Resgrid.Model.Repositories; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + /// + /// Replaces Identity's authenticator provider (registered under TokenOptions.DefaultAuthenticatorProvider in every + /// host) so each TOTP time step is accepted once per user across all nodes (plan section 7.5 rule 8). Every existing + /// VerifyTwoFactorTokenAsync call site and SignInManager.TwoFactorAuthenticatorSignInAsync go through it. + /// + public sealed class ResgridAuthenticatorTokenProvider : IUserTwoFactorTokenProvider + { + private readonly IUserMfaStateRepository _mfaState; + + public ResgridAuthenticatorTokenProvider(IUserMfaStateRepository mfaState) + { + _mfaState = mfaState; + } + + public async Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user) + => !string.IsNullOrWhiteSpace(await manager.GetAuthenticatorKeyAsync(user)); + + // Authenticator codes come from the user's app; like Identity's provider, nothing is generated server-side. + public Task GenerateAsync(string purpose, UserManager manager, IdentityUser user) + => Task.FromResult(string.Empty); + + public async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user) + { + var key = await manager.GetAuthenticatorKeyAsync(user); + return await ValidateAndConsumeAsync(_mfaState, user.Id, key, token, DateTime.UtcNow); + } + + /// + /// Verifies against and consumes the matched time step. Also used + /// to verify a staged (not yet active) authenticator key during setup or replacement. A replayed step, a malformed + /// input and a storage fault all return false; storage faults never accept a code. + /// + public static async Task ValidateAndConsumeAsync(IUserMfaStateRepository mfaState, string userId, string base32Key, + string code, DateTime utcNow, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) + return false; + + var step = TotpCalculator.FindMatchingTimeStep(base32Key, code, utcNow); + if (step == null) + return false; + + try + { + return await mfaState.TryConsumeTotpTimeStepAsync(userId, step.Value, utcNow, cancellationToken); + } + catch (Exception ex) + { + Logging.LogException(ex, "TOTP time-step consumption failed; the code was rejected."); + return false; + } + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Stores/TotpCalculator.cs b/Repositories/Resgrid.Repositories.DataRepository/Stores/TotpCalculator.cs new file mode 100644 index 000000000..be3a85529 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/Stores/TotpCalculator.cs @@ -0,0 +1,120 @@ +using System; +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; + +namespace Resgrid.Repositories.DataRepository.Stores +{ + /// + /// RFC 6238 TOTP (HMAC-SHA1, 6 digits, 30-second steps) matching ASP.NET Core Identity's authenticator provider, + /// but returning WHICH time step matched so the caller can consume it once. Identity's own provider only returns a + /// bool, which is why the same code could be replayed for its whole acceptance window (plan section 7.5 rule 8). + /// + public static class TotpCalculator + { + public const int StepSeconds = 30; + + /// Steps accepted on each side of the current one; the same ±2 window Identity's provider uses. + public const int DefaultWindow = 2; + + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + /// The time step Identity computes for (rounded seconds, integer division). + public static long CurrentTimeStep(DateTime utcNow) + => Convert.ToInt64(Math.Round((utcNow - UnixEpoch).TotalSeconds)) / StepSeconds; + + /// + /// Returns the time step whose code equals , or null for a malformed key, a malformed code or + /// no match. Every candidate step is computed, so timing does not reveal which step matched. + /// + public static long? FindMatchingTimeStep(string base32Key, string code, DateTime utcNow, int window = DefaultWindow) + { + if (string.IsNullOrWhiteSpace(base32Key) || !TryParseCode(code, out var expected)) + return null; + + byte[] key; + try + { + key = Base32Decode(base32Key); + } + catch (FormatException) + { + return null; + } + + if (key.Length == 0) + return null; + + var current = CurrentTimeStep(utcNow); + long? match = null; + for (var offset = -window; offset <= window; offset++) + { + var step = current + offset; + if (step < 0) + continue; + + if (ComputeCode(key, step) == expected && match == null) + match = step; + } + + return match; + } + + /// RFC 4226 HOTP value for one counter (6 digits). + public static int ComputeCode(byte[] key, long timeStep) + { + Span counter = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(counter, timeStep); + var hash = HMACSHA1.HashData(key, counter); + var offset = hash[^1] & 0x0F; + var binary = ((hash[offset] & 0x7F) << 24) + | (hash[offset + 1] << 16) + | (hash[offset + 2] << 8) + | hash[offset + 3]; + return binary % 1_000_000; + } + + /// RFC 4648 base32 (the alphabet Identity uses for authenticator keys); case, spaces and padding ignored. + public static byte[] Base32Decode(string input) + { + var clean = input.Replace(" ", string.Empty).Replace("-", string.Empty).TrimEnd('=').ToUpperInvariant(); + var output = new byte[clean.Length * 5 / 8]; + int buffer = 0, bits = 0, index = 0; + foreach (var character in clean) + { + var value = Base32Alphabet.IndexOf(character); + if (value < 0) + throw new FormatException("Authenticator key is not valid base32."); + + buffer = (buffer << 5) | value; + bits += 5; + if (bits >= 8) + { + output[index++] = (byte)(buffer >> (bits - 8)); + bits -= 8; + } + } + + return output; + } + + // Same acceptance as Identity (an integer, so "012345" and "12345" are equal) but digits only and bounded. + private static bool TryParseCode(string code, out int value) + { + value = 0; + if (string.IsNullOrWhiteSpace(code)) + return false; + + var clean = code.Replace(" ", string.Empty).Replace("-", string.Empty).Trim(); + if (clean.Length == 0 || clean.Length > 8) + return false; + + foreach (var character in clean) + if (character < '0' || character > '9') + return false; + + return int.TryParse(clean, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserMfaStateRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserMfaStateRepository.cs new file mode 100644 index 000000000..7799b10e5 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UserMfaStateRepository.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Compare-and-set MFA factor state (workbook section 8.3). Each call opens its own connection so a consume never + /// shares an ambient unit of work, and every consume is a single guarded statement that succeeds on exactly one row. + /// + public sealed class UserMfaStateRepository : IUserMfaStateRepository + { + // The legacy ";"-joined plaintext recovery codes written by IdentityUserStore before M0243. + internal const string LegacyRecoveryLoginProvider = "[AspNetUserStore]"; + internal const string LegacyRecoveryTokenName = "RecoveryCodes"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _totp; + private readonly string _codes; + private readonly string _preferences; + + public UserMfaStateRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _totp = configuration.SchemaName + (_postgres ? ".usertotpstates" : ".[UserTotpStates]"); + _codes = configuration.SchemaName + (_postgres ? ".userrecoverycodes" : ".[UserRecoveryCodes]"); + _preferences = configuration.SchemaName + (_postgres ? ".usermfapreferences" : ".[UserMfaPreferences]"); + } + + public async Task GetPreferredMethodAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT PreferredMethod FROM {_preferences} WHERE UserId = @UserId", + new { UserId = userId }, cancellationToken: cancellationToken)); + } + + public async Task SetPreferredMethodAsync(string userId, int method, DateTime utcNow, CancellationToken cancellationToken = default) + { + // Last writer wins; this is a display default, not an authorization input. + var sql = _postgres + ? $@"INSERT INTO {_preferences} (userid, preferredmethod, updatedonutc) VALUES (@UserId, @Method, @Now) + ON CONFLICT (userid) DO UPDATE SET preferredmethod = EXCLUDED.preferredmethod, updatedonutc = EXCLUDED.updatedonutc" + : $@"MERGE {_preferences} WITH (HOLDLOCK) AS target + USING (SELECT @UserId AS UserId) AS source ON target.UserId = source.UserId + WHEN MATCHED THEN UPDATE SET PreferredMethod = @Method, UpdatedOnUtc = @Now + WHEN NOT MATCHED THEN INSERT (UserId, PreferredMethod, UpdatedOnUtc) VALUES (@UserId, @Method, @Now);"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition(sql, + new { UserId = userId, Method = method, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task TryConsumeTotpTimeStepAsync(string userId, long timeStep, DateTime utcNow, CancellationToken cancellationToken = default) + { + // One statement on both engines: insert the first step, or advance only to a strictly newer step. + // Zero rows changed means this step (or a later one) was already accepted. + var sql = _postgres + ? $@"INSERT INTO {_totp} AS t (userid, lastacceptedtimestep, lastacceptedonutc) VALUES (@UserId, @TimeStep, @Now) + ON CONFLICT (userid) DO UPDATE SET lastacceptedtimestep = EXCLUDED.lastacceptedtimestep, lastacceptedonutc = EXCLUDED.lastacceptedonutc + WHERE t.lastacceptedtimestep < EXCLUDED.lastacceptedtimestep" + : $@"MERGE {_totp} WITH (HOLDLOCK) AS target + USING (SELECT @UserId AS UserId) AS source ON target.UserId = source.UserId + WHEN MATCHED AND target.LastAcceptedTimeStep < @TimeStep THEN + UPDATE SET LastAcceptedTimeStep = @TimeStep, LastAcceptedOnUtc = @Now + WHEN NOT MATCHED THEN + INSERT (UserId, LastAcceptedTimeStep, LastAcceptedOnUtc) VALUES (@UserId, @TimeStep, @Now);"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition(sql, + new { UserId = userId, TimeStep = timeStep, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task GetTotpStateAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT UserId, LastAcceptedTimeStep, LastAcceptedOnUtc, EnrolledOnUtc, EnrolledInSharedMode, EnrolledClientApplication, EnrolledInstallation FROM {_totp} WHERE UserId = @UserId", + new { UserId = userId }, cancellationToken: cancellationToken)); + } + + public async Task RecordTotpEnrollmentAsync(string userId, DateTime utcNow, TotpEnrollmentContext context, CancellationToken cancellationToken = default) + { + var installation = string.IsNullOrWhiteSpace(context?.Installation) ? null : context.Installation.Trim(); + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_totp} SET EnrolledOnUtc = @Now, EnrolledInSharedMode = @SharedMode, EnrolledClientApplication = @Client, + EnrolledInstallation = @Installation WHERE UserId = @UserId", + new + { + UserId = userId, Now = Timestamp(utcNow), SharedMode = context?.SharedMode == true, Client = context?.ClientApplication, + Installation = installation?.Length > 256 ? installation.Substring(0, 256) : installation + }, cancellationToken: cancellationToken)); + } + + public async Task CountUnusedRecoveryCodesAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_codes} WHERE UserId = @UserId AND UsedOnUtc IS NULL", + new { UserId = userId }, cancellationToken: cancellationToken)); + } + + public async Task TryRedeemRecoveryCodeAsync(string userId, byte[] codeHash, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_codes} SET UsedOnUtc = @Now WHERE UserId = @UserId AND CodeHash = @CodeHash AND UsedOnUtc IS NULL", + new { UserId = userId, CodeHash = codeHash, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)) == 1; + } + + public async Task ReplaceRecoveryCodesAsync(string userId, IReadOnlyCollection codeHashes, int hashVersion, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + // Delete the legacy token FIRST. A concurrent ImportLegacyRecoveryCodesAsync holds that row while it inserts the + // old codes; waiting on it here means the row delete below also removes whatever it imported, so old codes can + // never survive a regeneration. + await DeleteLegacyTokenAsync(connection, transaction, userId, null, cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_codes} WHERE UserId = @UserId", new { UserId = userId }, transaction, cancellationToken: cancellationToken)); + await InsertCodesAsync(connection, transaction, userId, codeHashes, hashVersion, utcNow, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task ImportLegacyRecoveryCodesAsync(string userId, string legacyTokenValue, IReadOnlyCollection codeHashes, + int hashVersion, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + // Compare-and-delete on the exact value read: only one request migrates, and a token that changed since it was + // read (or was already migrated) is left alone. + if (await DeleteLegacyTokenAsync(connection, transaction, userId, legacyTokenValue, cancellationToken) != 1) + { + await transaction.RollbackAsync(cancellationToken); + return false; + } + + await InsertCodesAsync(connection, transaction, userId, codeHashes, hashVersion, utcNow, cancellationToken); + await transaction.CommitAsync(cancellationToken); + return true; + } + + private static Task DeleteLegacyTokenAsync(DbConnection connection, DbTransaction transaction, string userId, string expectedValue, + CancellationToken cancellationToken) + { + // Unqualified, unquoted names resolve to dbo.AspNetUserTokens and public.aspnetusertokens, as in IdentityUserRepository. + var sql = "DELETE FROM AspNetUserTokens WHERE UserId = @UserId AND LoginProvider = @LoginProvider AND Name = @Name" + + (expectedValue == null ? string.Empty : " AND Value = @Value"); + return connection.ExecuteAsync(new CommandDefinition(sql, new + { + UserId = userId, + LoginProvider = LegacyRecoveryLoginProvider, + Name = LegacyRecoveryTokenName, + Value = expectedValue + }, transaction, cancellationToken: cancellationToken)); + } + + private Task InsertCodesAsync(DbConnection connection, DbTransaction transaction, string userId, IReadOnlyCollection codeHashes, + int hashVersion, DateTime utcNow, CancellationToken cancellationToken) + { + if (codeHashes == null || codeHashes.Count == 0) + return Task.CompletedTask; + + var created = Timestamp(utcNow); + var rows = codeHashes.Distinct(ByteArrayComparer.Instance).Select(hash => new + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + CodeHash = hash, + HashVersion = hashVersion, + Now = created + }).ToList(); + + return connection.ExecuteAsync(new CommandDefinition( + $"INSERT INTO {_codes} (UserRecoveryCodeId, UserId, CodeHash, HashVersion, CreatedOnUtc) VALUES (@Id, @UserId, @CodeHash, @HashVersion, @Now)", + rows, transaction, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utcNow) => _postgres ? DateTime.SpecifyKind(utcNow, DateTimeKind.Unspecified) : utcNow; + + private sealed class ByteArrayComparer : IEqualityComparer + { + public static readonly ByteArrayComparer Instance = new(); + public bool Equals(byte[] x, byte[] y) => x.AsSpan().SequenceEqual(y); + public int GetHashCode(byte[] obj) => obj.Length >= 4 ? BitConverter.ToInt32(obj, 0) : obj.Length; + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserPasskeyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserPasskeyRepository.cs new file mode 100644 index 000000000..428e5565c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UserPasskeyRepository.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Registered passkeys (workbook section 8.3): every change is one guarded statement on its own connection whose + /// success is the rows it changed. Registration inserts only when the RP does not already hold the credential id + /// (PostgreSQL ON CONFLICT DO NOTHING; SQL Server MERGE with HOLDLOCK), so two nodes can never attach one credential to + /// two users. A use is recorded only if the signature counter is still the one the assertion was verified against. + /// + public sealed class UserPasskeyRepository : IUserPasskeyRepository + { + private const string Columns = @"UserPasskeyId, UserId, ClientApplication, RpId, CredentialId, CredentialIdHash, PublicKey, Algorithm, + UserHandle, SignCount, IsBackupEligible, IsBackedUp, Transports, Aaguid, AttestationFormat, DisplayName, CreatedOnUtc, + RegistrationPlatform, RegistrationInstallation, RegistrationUserAgentFamily, RegistrationAttachment, RegisteredInSharedMode, + LastUsedOnUtc, LastUsedClientApplication, LastUsedInstallation, LastUsedInSharedMode, ApprovalEnabled, RevokedOnUtc, + RevocationReason, RevokedByUserId, StateVersion"; + + private const string InsertValues = @"@UserPasskeyId, @UserId, @ClientApplication, @RpId, @CredentialId, @CredentialIdHash, @PublicKey, + @Algorithm, @UserHandle, @SignCount, @IsBackupEligible, @IsBackedUp, @Transports, @Aaguid, @AttestationFormat, @DisplayName, + @CreatedOnUtc, @RegistrationPlatform, @RegistrationInstallation, @RegistrationUserAgentFamily, @RegistrationAttachment, + @RegisteredInSharedMode, NULL, NULL, NULL, @False, @False, NULL, NULL, NULL, 1"; + + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public UserPasskeyRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".userpasskeys" : ".[UserPasskeys]"); + } + + public async Task TryInsertAsync(UserPasskey passkey, CancellationToken cancellationToken = default) + { + var sql = _postgres + ? $@"INSERT INTO {_table} ({Columns}) VALUES ({InsertValues}) + ON CONFLICT (rpid, credentialidhash) DO NOTHING" + : $@"MERGE {_table} WITH (HOLDLOCK) AS target + USING (SELECT @RpId AS RpId, @CredentialIdHash AS CredentialIdHash) AS source + ON target.RpId = source.RpId AND target.CredentialIdHash = source.CredentialIdHash + WHEN NOT MATCHED THEN + INSERT ({Columns}) VALUES ({InsertValues});"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition(sql, new + { + passkey.UserPasskeyId, + passkey.UserId, + passkey.ClientApplication, + passkey.RpId, + passkey.CredentialId, + passkey.CredentialIdHash, + passkey.PublicKey, + passkey.Algorithm, + passkey.UserHandle, + passkey.SignCount, + passkey.IsBackupEligible, + passkey.IsBackedUp, + passkey.Transports, + passkey.Aaguid, + passkey.AttestationFormat, + passkey.DisplayName, + CreatedOnUtc = Timestamp(passkey.CreatedOnUtc), + passkey.RegistrationPlatform, + passkey.RegistrationInstallation, + passkey.RegistrationUserAgentFamily, + passkey.RegistrationAttachment, + passkey.RegisteredInSharedMode, + False = false + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task GetAsync(string userPasskeyId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE UserPasskeyId = @Id", + new { Id = userPasskeyId }, cancellationToken: cancellationToken)); + } + + public async Task GetActiveByCredentialAsync(string rpId, byte[] credentialIdHash, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE RpId = @RpId AND CredentialIdHash = @Hash AND RevokedOnUtc IS NULL", + new { RpId = rpId, Hash = credentialIdHash }, cancellationToken: cancellationToken)); + } + + public async Task> GetActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + var rows = await connection.QueryAsync(new CommandDefinition( + $"SELECT {Columns} FROM {_table} WHERE UserId = @UserId AND RevokedOnUtc IS NULL ORDER BY ClientApplication, CreatedOnUtc", + new { UserId = userId }, cancellationToken: cancellationToken)); + return rows.ToList(); + } + + public async Task CountActiveForUserAsync(string userId, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new CommandDefinition( + $"SELECT COUNT(*) FROM {_table} WHERE UserId = @UserId AND RevokedOnUtc IS NULL", + new { UserId = userId }, cancellationToken: cancellationToken)); + } + + public async Task GetUserHandleAsync(string userId, string rpId, CancellationToken cancellationToken = default) + { + var sql = _postgres + ? $"SELECT UserHandle FROM {_table} WHERE UserId = @UserId AND RpId = @RpId ORDER BY CreatedOnUtc LIMIT 1" + : $"SELECT TOP 1 UserHandle FROM {_table} WHERE UserId = @UserId AND RpId = @RpId ORDER BY CreatedOnUtc"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition(sql, + new { UserId = userId, RpId = rpId }, cancellationToken: cancellationToken)); + } + + public async Task TryRenameAsync(string userPasskeyId, string userId, string displayName, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET DisplayName = @DisplayName + WHERE UserPasskeyId = @Id AND UserId = @UserId AND RevokedOnUtc IS NULL", + new { Id = userPasskeyId, UserId = userId, DisplayName = displayName }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TryRevokeAsync(string userPasskeyId, string userId, PasskeyRevocationReason reason, string actorUserId, DateTime utcNow, + CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET RevokedOnUtc = @Now, RevocationReason = @Reason, RevokedByUserId = @Actor, + ApprovalEnabled = @False, StateVersion = StateVersion + 1 + WHERE UserPasskeyId = @Id AND UserId = @UserId AND RevokedOnUtc IS NULL", + new { Id = userPasskeyId, UserId = userId, Reason = (int)reason, Actor = actorUserId, Now = Timestamp(utcNow), False = false }, + cancellationToken: cancellationToken)) == 1; + } + + public async Task> RevokeAllForClientAsync(string userId, int clientApplication, PasskeyRevocationReason reason, + string actorUserId, DateTime utcNow, CancellationToken cancellationToken = default) + { + const string Set = @"SET RevokedOnUtc = @Now, RevocationReason = @Reason, RevokedByUserId = @Actor, + ApprovalEnabled = @False, StateVersion = StateVersion + 1"; + const string Where = "WHERE UserId = @UserId AND ClientApplication = @Client AND RevokedOnUtc IS NULL"; + + // One statement reports exactly the rows it revoked, so the caller retires evidence for those and no others. + var sql = _postgres + ? $"UPDATE {_table} {Set} {Where} RETURNING UserPasskeyId" + : $"UPDATE {_table} {Set} OUTPUT inserted.UserPasskeyId {Where}"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + var ids = await connection.QueryAsync(new CommandDefinition(sql, + new { UserId = userId, Client = clientApplication, Reason = (int)reason, Actor = actorUserId, Now = Timestamp(utcNow), False = false }, + cancellationToken: cancellationToken)); + return ids.ToList(); + } + + public async Task TryRecordUseAsync(string userPasskeyId, long expectedSignCount, long newSignCount, bool isBackedUp, + int clientApplication, string installation, bool sharedMode, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET SignCount = @NewSignCount, IsBackedUp = @IsBackedUp, LastUsedOnUtc = @Now, + LastUsedClientApplication = @Client, LastUsedInstallation = @Installation, LastUsedInSharedMode = @SharedMode + WHERE UserPasskeyId = @Id AND SignCount = @ExpectedSignCount AND RevokedOnUtc IS NULL", + new + { + Id = userPasskeyId, + ExpectedSignCount = expectedSignCount, + NewSignCount = newSignCount, + IsBackedUp = isBackedUp, + Client = clientApplication, + Installation = installation, + SharedMode = sharedMode, + Now = Timestamp(utcNow) + }, cancellationToken: cancellationToken)) == 1; + } + + public async Task TrySetApprovalEnabledAsync(string userPasskeyId, string userId, bool enabled, CancellationToken cancellationToken = default) + { + // Changing what a credential may approve advances its state version, so anything derived from the old + // setting can be told apart (plan section 7.9). + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $@"UPDATE {_table} SET ApprovalEnabled = @Enabled, StateVersion = StateVersion + 1 + WHERE UserPasskeyId = @Id AND UserId = @UserId AND RevokedOnUtc IS NULL", + new { Id = userPasskeyId, UserId = userId, Enabled = enabled }, cancellationToken: cancellationToken)) == 1; + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserSessionMfaEvidenceRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserSessionMfaEvidenceRepository.cs new file mode 100644 index 000000000..4a7ae35a2 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/UserSessionMfaEvidenceRepository.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Security; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Server-side MFA evidence per session (passkey plan section 5.3). Own connection per call. + public sealed class UserSessionMfaEvidenceRepository : IUserSessionMfaEvidenceRepository + { + private readonly IConnectionProvider _connections; + private readonly bool _postgres; + private readonly string _table; + + public UserSessionMfaEvidenceRepository(IConnectionProvider connections, SqlConfiguration configuration) + { + _connections = connections; + _postgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = configuration.SchemaName + (_postgres ? ".usersessionmfaevidence" : ".[UserSessionMfaEvidence]"); + } + + public async Task InsertAsync(MfaEvidence evidence, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync(new CommandDefinition( + $@"INSERT INTO {_table} (MfaEvidenceId, UserId, SessionKey, ClientApplication, Kind, Method, Purpose, DepartmentId, + VerifiedOnUtc, ExpiresOnUtc, AuthenticationGeneration, FactorReference) + VALUES (@MfaEvidenceId, @UserId, @SessionKey, @ClientApplication, @Kind, @Method, @Purpose, @DepartmentId, + @VerifiedOnUtc, @ExpiresOnUtc, @AuthenticationGeneration, @FactorReference)", + new + { + evidence.MfaEvidenceId, + evidence.UserId, + evidence.SessionKey, + evidence.ClientApplication, + evidence.Kind, + evidence.Method, + evidence.Purpose, + evidence.DepartmentId, + VerifiedOnUtc = Timestamp(evidence.VerifiedOnUtc), + ExpiresOnUtc = Timestamp(evidence.ExpiresOnUtc), + evidence.AuthenticationGeneration, + evidence.FactorReference + }, cancellationToken: cancellationToken)); + } + + public Task GetLatestAsync(string userId, string sessionKey, MfaEvidenceKind kind, long authenticationGeneration, + DateTime utcNow, CancellationToken cancellationToken = default) => + QueryLatestAsync(userId, sessionKey, kind, null, authenticationGeneration, utcNow, cancellationToken); + + public Task GetLatestForPurposeAsync(string userId, string sessionKey, MfaEvidenceKind kind, MfaEvidencePurpose purpose, + long authenticationGeneration, DateTime utcNow, CancellationToken cancellationToken = default) => + QueryLatestAsync(userId, sessionKey, kind, purpose, authenticationGeneration, utcNow, cancellationToken); + + private async Task QueryLatestAsync(string userId, string sessionKey, MfaEvidenceKind kind, MfaEvidencePurpose? purpose, + long authenticationGeneration, DateTime utcNow, CancellationToken cancellationToken) + { + const string columns = @"MfaEvidenceId, UserId, SessionKey, ClientApplication, Kind, Method, Purpose, DepartmentId, + VerifiedOnUtc, ExpiresOnUtc, AuthenticationGeneration, FactorReference, RevokedOnUtc"; + var filter = @"WHERE UserId = @UserId AND SessionKey = @SessionKey AND Kind = @Kind + AND AuthenticationGeneration = @Generation AND RevokedOnUtc IS NULL AND ExpiresOnUtc > @Now" + + (purpose == null ? string.Empty : " AND Purpose = @Purpose"); + var sql = _postgres + ? $"SELECT {columns} FROM {_table} {filter} ORDER BY VerifiedOnUtc DESC LIMIT 1" + : $"SELECT TOP (1) {columns} FROM {_table} {filter} ORDER BY VerifiedOnUtc DESC"; + + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.QuerySingleOrDefaultAsync(new CommandDefinition(sql, new + { + UserId = userId, + SessionKey = sessionKey, + Kind = (int)kind, + Purpose = (int)(purpose ?? 0), + Generation = authenticationGeneration, + Now = Timestamp(utcNow) + }, cancellationToken: cancellationToken)); + } + + public async Task RevokeForUserAsync(string userId, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET RevokedOnUtc = @Now WHERE UserId = @UserId AND RevokedOnUtc IS NULL", + new { UserId = userId, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task RevokeForFactorAsync(string userId, string factorReference, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET RevokedOnUtc = @Now WHERE UserId = @UserId AND FactorReference = @FactorReference AND RevokedOnUtc IS NULL", + new { UserId = userId, FactorReference = factorReference, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task RevokeByFactorReferenceAsync(string factorReference, DateTime utcNow, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"UPDATE {_table} SET RevokedOnUtc = @Now WHERE FactorReference = @FactorReference AND RevokedOnUtc IS NULL", + new { FactorReference = factorReference, Now = Timestamp(utcNow) }, cancellationToken: cancellationToken)); + } + + public async Task PurgeExpiredBeforeAsync(DateTime utcCutoff, CancellationToken cancellationToken = default) + { + await using var connection = _connections.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteAsync(new CommandDefinition( + $"DELETE FROM {_table} WHERE ExpiresOnUtc < @Cutoff", + new { Cutoff = Timestamp(utcCutoff) }, cancellationToken: cancellationToken)); + } + + // Npgsql refuses Kind=Utc for "timestamp without time zone"; SQL Server ignores Kind. + private DateTime Timestamp(DateTime utc) => _postgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs index 423b83ca6..7b6d2e542 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/UserSessionsRepository.cs @@ -146,20 +146,23 @@ private async Task GuardedInsertAsync(DbConnection connection, DbTransacti applicationversion, authenticationmethod, departmentssoconfigid, openiddictauthorizationid, webcookieticketkey, createdon, lastactiveon, expireson, firstipaddress, lastipaddress, lastcountry, lastregion, lastcity, useragent, islegacyadopted, revokedon, revokedbyuserid, - revocationreason" + revocationreason, loginmfamethod, loginmfafactorreference, sharedmode, sharedmodesource, + sharedidlelockminutes, lockversion, islocked, lockedonutc, lockreason, lastoperatoractivityon, approvalsdisabledonutc" : @"[UserSessionId], [UserId], [DepartmentId], [AuthenticationGeneration], [State], [StateVersion], [ClientApplication], [ClientInstanceIdHash], [DeviceName], [DeviceType], [OperatingSystem], [Browser], [ApplicationVersion], [AuthenticationMethod], [DepartmentSsoConfigId], [OpenIddictAuthorizationId], [WebCookieTicketKey], [CreatedOn], [LastActiveOn], [ExpiresOn], [FirstIpAddress], [LastIpAddress], [LastCountry], [LastRegion], [LastCity], [UserAgent], [IsLegacyAdopted], [RevokedOn], [RevokedByUserId], - [RevocationReason]"; + [RevocationReason], [LoginMfaMethod], [LoginMfaFactorReference], [SharedMode], [SharedModeSource], + [SharedIdleLockMinutes], [LockVersion], [IsLocked], [LockedOnUtc], [LockReason], [LastOperatorActivityOn], [ApprovalsDisabledOnUtc]"; const string values = @"@UserSessionId, @UserId, @DepartmentId, @AuthenticationGeneration, @State, @StateVersion, @ClientApplication, @ClientInstanceIdHash, @DeviceName, @DeviceType, @OperatingSystem, @Browser, @ApplicationVersion, @AuthenticationMethod, @DepartmentSsoConfigId, @OpenIddictAuthorizationId, @WebCookieTicketKey, @CreatedOn, @LastActiveOn, @ExpiresOn, @FirstIpAddress, @LastIpAddress, @LastCountry, @LastRegion, @LastCity, @UserAgent, @IsLegacyAdopted, @RevokedOn, @RevokedByUserId, - @RevocationReason"; + @RevocationReason, @LoginMfaMethod, @LoginMfaFactorReference, @SharedMode, @SharedModeSource, + @SharedIdleLockMinutes, @LockVersion, @IsLocked, @LockedOnUtc, @LockReason, @LastOperatorActivityOn, @ApprovalsDisabledOnUtc"; // The counted set mirrors the policy rule exactly: active, unexpired sessions this user holds in // the department that were created on or after the policy gate. @@ -298,6 +301,125 @@ OR expireson < @HistoryBeforeUtc" }, cancellationToken); } + public Task TryLockAsync(string sessionId, long expectedLockVersion, int reason, DateTime lockedOnUtc, + CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"UPDATE {_table} SET islocked = @True, lockversion = lockversion + 1, lockedonutc = @LockedOnUtc, + lockreason = @Reason, stateversion = stateversion + 1 + WHERE usersessionid = @SessionId AND state = @ActiveState AND sharedmode = @True AND islocked = @False + AND lockversion = @ExpectedLockVersion" + : $@"UPDATE {_table} SET [IsLocked] = @True, [LockVersion] = [LockVersion] + 1, [LockedOnUtc] = @LockedOnUtc, + [LockReason] = @Reason, [StateVersion] = [StateVersion] + 1 + WHERE [UserSessionId] = @SessionId AND [State] = @ActiveState AND [SharedMode] = @True AND [IsLocked] = @False + AND [LockVersion] = @ExpectedLockVersion"; + + return ExecuteAsync(sql, new + { + SessionId = sessionId, + ExpectedLockVersion = expectedLockVersion, + Reason = reason, + LockedOnUtc = Timestamp(lockedOnUtc), + ActiveState = (int)UserSessionState.Active, + True = true, + False = false + }, cancellationToken); + } + + public Task TryUnlockAsync(string userId, string sessionId, long expectedLockVersion, DateTime unlockedOnUtc, + CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $@"UPDATE {_table} SET islocked = @False, lastoperatoractivityon = @UnlockedOnUtc, stateversion = stateversion + 1 + WHERE usersessionid = @SessionId AND userid = @UserId AND state = @ActiveState AND sharedmode = @True + AND islocked = @True AND lockversion = @ExpectedLockVersion AND expireson > @UnlockedOnUtc" + : $@"UPDATE {_table} SET [IsLocked] = @False, [LastOperatorActivityOn] = @UnlockedOnUtc, [StateVersion] = [StateVersion] + 1 + WHERE [UserSessionId] = @SessionId AND [UserId] = @UserId AND [State] = @ActiveState AND [SharedMode] = @True + AND [IsLocked] = @True AND [LockVersion] = @ExpectedLockVersion AND [ExpiresOn] > @UnlockedOnUtc"; + + return ExecuteAsync(sql, new + { + UserId = userId, + SessionId = sessionId, + ExpectedLockVersion = expectedLockVersion, + UnlockedOnUtc = Timestamp(unlockedOnUtc), + ActiveState = (int)UserSessionState.Active, + True = true, + False = false + }, cancellationToken); + } + + public Task RecordOperatorActivityAsync(string sessionId, DateTime occurredOnUtc, DateTime writeBefore, DateTime idleCutoff, + CancellationToken cancellationToken) + { + // A session created before this column existed has no activity yet; its creation time stands in, exactly as the + // idle deadline reads it. + var sql = _isPostgres + ? $@"UPDATE {_table} SET lastoperatoractivityon = @OccurredOnUtc + WHERE usersessionid = @SessionId AND state = @ActiveState AND sharedmode = @True AND islocked = @False + AND COALESCE(lastoperatoractivityon, createdon) <= @WriteBefore + AND COALESCE(lastoperatoractivityon, createdon) > @IdleCutoff" + : $@"UPDATE {_table} SET [LastOperatorActivityOn] = @OccurredOnUtc + WHERE [UserSessionId] = @SessionId AND [State] = @ActiveState AND [SharedMode] = @True AND [IsLocked] = @False + AND COALESCE([LastOperatorActivityOn], [CreatedOn]) <= @WriteBefore + AND COALESCE([LastOperatorActivityOn], [CreatedOn]) > @IdleCutoff"; + + return ExecuteAsync(sql, new + { + SessionId = sessionId, + OccurredOnUtc = Timestamp(occurredOnUtc), + WriteBefore = Timestamp(writeBefore), + IdleCutoff = Timestamp(idleCutoff), + ActiveState = (int)UserSessionState.Active, + True = true, + False = false + }, cancellationToken); + } + + public Task DisableApprovalsAsync(string userId, string sessionId, DateTime disabledOnUtc, CancellationToken cancellationToken) + { + // One installation, or (no session id) every one; the filter is left out rather than sent as a null parameter. + var sql = _isPostgres + ? $@"UPDATE {_table} SET approvalsdisabledonutc = @Now + WHERE userid = @UserId AND state = @ActiveState AND clientapplication = @Responder AND approvalsdisabledonutc IS NULL + {(sessionId == null ? "" : "AND usersessionid = @SessionId")}" + : $@"UPDATE {_table} SET [ApprovalsDisabledOnUtc] = @Now + WHERE [UserId] = @UserId AND [State] = @ActiveState AND [ClientApplication] = @Responder AND [ApprovalsDisabledOnUtc] IS NULL + {(sessionId == null ? "" : "AND [UserSessionId] = @SessionId")}"; + + return ExecuteAsync(sql, new + { + UserId = userId, + SessionId = sessionId, + Now = Timestamp(disabledOnUtc), + ActiveState = (int)UserSessionState.Active, + Responder = (int)UserSessionClientApplication.Responder + }, cancellationToken); + } + + public async Task> GetStatesAsync(IReadOnlyCollection sessionIds, CancellationToken cancellationToken) + { + var result = new List(); + if (sessionIds == null || sessionIds.Count == 0) + return result; + + var sql = _isPostgres + ? $@"SELECT usersessionid AS UserSessionId, state AS State, expireson AS ExpiresOn, createdon AS CreatedOn, sharedmode AS SharedMode, + islocked AS IsLocked, sharedidlelockminutes AS SharedIdleLockMinutes, lastoperatoractivityon AS LastOperatorActivityOn + FROM {_table} WHERE usersessionid = ANY(@Ids)" + : $@"SELECT [UserSessionId], [State], [ExpiresOn], [CreatedOn], [SharedMode], [IsLocked], [SharedIdleLockMinutes], [LastOperatorActivityOn] + FROM {_table} WHERE [UserSessionId] IN @Ids"; + + foreach (var batch in sessionIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).Chunk(500)) + { + var rows = await WithConnectionAsync(connection => connection.QueryAsync(new Dapper.CommandDefinition(sql, + new { Ids = batch }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + result.AddRange(rows); + } + + return result; + } + private Task RevokeWhereAsync(string predicate, object values, string actorUserId, int reason, DateTime revokedOn, CancellationToken cancellationToken) { @@ -318,6 +440,12 @@ private Task RevokeWhereAsync(string predicate, object values, string actor return ExecuteAsync(sql, parameters, cancellationToken); } + /// + /// The shared-session statements run on every host (the broker and Eventing validate sessions too), not only where + /// Npgsql's legacy timestamp behavior is switched on, so their times go to PostgreSQL as zone-less values. + /// + private DateTime Timestamp(DateTime utc) => _isPostgres ? DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) : utc; + private Task ExecuteAsync(string sql, object parameters, CancellationToken cancellationToken) { return WithConnectionAsync(connection => connection.ExecuteAsync( diff --git a/Resgrid.sln b/Resgrid.sln index 6f20e449b..4e43069c3 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -118,6 +118,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Common", "Web\R EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.ProtectedData", "Providers\Resgrid.Providers.ProtectedData\Resgrid.Providers.ProtectedData.csproj", "{B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Authentication", "Providers\Resgrid.Providers.Authentication\Resgrid.Providers.Authentication.csproj", "{8CC7A836-3468-4896-9411-43DA35671800}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Broker", "Web\Resgrid.Web.Broker\Resgrid.Web.Broker.csproj", "{19AD2004-864D-4E68-8B3F-CE7116BD84AF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Search", "Core\Resgrid.Search\Resgrid.Search.csproj", "{62BCBAE8-C7A3-4C5B-80C4-7C7310CB71B4}" @@ -1740,6 +1742,42 @@ Global {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x86.Build.0 = Debug|Any CPU {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x64.ActiveCfg = Debug|Any CPU {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x64.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|Any CPU.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|x86.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|x86.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|x64.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Azure|x64.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|x86.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|x86.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|x64.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Cloud|x64.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|x86.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|x86.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|x64.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Debug|x64.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|Any CPU.Build.0 = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|x86.ActiveCfg = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|x86.Build.0 = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|x64.ActiveCfg = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Docker|x64.Build.0 = Docker|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|Any CPU.Build.0 = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|x86.ActiveCfg = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|x86.Build.0 = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|x64.ActiveCfg = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Release|x64.Build.0 = Release|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|Any CPU.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|x86.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|x86.Build.0 = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|x64.ActiveCfg = Debug|Any CPU + {8CC7A836-3468-4896-9411-43DA35671800}.Staging|x64.Build.0 = Debug|Any CPU {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|Any CPU.ActiveCfg = Debug|Any CPU {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|Any CPU.Build.0 = Debug|Any CPU {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|x86.ActiveCfg = Debug|Any CPU @@ -2078,6 +2116,7 @@ Global {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE} = {D2D96CD8-CD7D-414D-8B33-A6C363B40C8D} {7B0856E5-C37C-4EA6-9A69-FDF5F772B249} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} + {8CC7A836-3468-4896-9411-43DA35671800} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} {19AD2004-864D-4E68-8B3F-CE7116BD84AF} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} {62BCBAE8-C7A3-4C5B-80C4-7C7310CB71B4} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} {7D1F3C2A-5B8E-4E61-9A0C-3F2B6C9D8E41} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583} diff --git a/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs index ea2e3887e..8dd25b2d9 100644 --- a/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs +++ b/Tests/Resgrid.Tests/AdminAssist/AdminAssistDatabaseTests.cs @@ -346,17 +346,18 @@ public async Task Security_projection_reads_only_bounded_tenant_policy_member_an var text = type == DatabaseTypes.Postgres ? "varchar" : "nvarchar"; var date = type == DatabaseTypes.Postgres ? "timestamp" : "datetime2"; var boolean = type == DatabaseTypes.Postgres ? "boolean" : "bit"; - await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentSecurityPolicies")} ({Q("DepartmentId")} int,{Q("RequireMfa")} {boolean},{Q("RequireSso")} {boolean},{Q("SessionTimeoutMinutes")} int,{Q("MaxConcurrentSessions")} int,{Q("PasswordExpirationDays")} int,{Q("MinPasswordLength")} int)"); + await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentSecurityPolicies")} ({Q("DepartmentId")} int,{Q("RequireMfa")} {boolean},{Q("RequireSso")} {boolean},{Q("SessionTimeoutMinutes")} int,{Q("MaxConcurrentSessions")} int,{Q("PasswordExpirationDays")} int,{Q("MinPasswordLength")} int,{Q("AllowPasskeysForLoginMfa")} {boolean},{Q("AllowPasskeysForAdp")} {boolean},{Q("AllowFederatedMfaForLoginMfa")} {boolean},{Q("AllowFederatedMfaForAdp")} {boolean},{Q("AllowResponderApproval")} {boolean},{Q("AcceptRecentLoginMfaForAdp")} {boolean},{Q("AcceptRecentUnlockMfaForAdp")} {boolean},{Q("MfaPolicyVersion")} bigint)"); await db.ExecuteAsync($"CREATE TABLE {Q("DepartmentSsoConfigs")} ({Q("DepartmentId")} int,{Q("IsEnabled")} {boolean})"); await db.ExecuteAsync($"CREATE TABLE {Q("UserSessions")} ({Q("UserSessionId")} {text}(128),{Q("UserId")} {text}(128),{Q("DepartmentId")} int,{Q("State")} int,{Q("CreatedOn")} {date},{Q("LastActiveOn")} {date},{Q("ExpiresOn")} {date},{Q("AuthenticationGeneration")} bigint)"); var now = new DateTime(2026,9,24,12,0,0); var args = new { False = false, True = true, Now = now, Old = now.AddHours(-1), Future = now.AddHours(1) }; await db.ExecuteAsync($"INSERT INTO {Q("DepartmentMembers")} ({Q("DepartmentMemberId")},{Q("DepartmentId")},{Q("UserId")},{Q("IsDeleted")},{Q("IsDisabled")},{Q("IsHidden")},{Q("PasswordLastSetOn")}) VALUES (1,709,'security-a',@False,@False,@True,@Old),(2,709,'security-missing',@False,@False,@False,NULL),(3,709,'security-disabled',@False,@True,@False,NULL),(4,710,'security-other',@False,@False,@False,NULL)",args); - await db.ExecuteAsync($"INSERT INTO {Q("AspNetUsers")} ({Q("Id")},{Q("TwoFactorEnabled")},{Q("AuthenticationGeneration")}) VALUES ('security-a',@True,4); INSERT INTO {Q("DepartmentSsoConfigs")} VALUES (709,@True),(709,@False),(710,@True); INSERT INTO {Q("DepartmentSecurityPolicies")} VALUES (709,@True,@False,30,2,90,12)",args); + await db.ExecuteAsync($"INSERT INTO {Q("AspNetUsers")} ({Q("Id")},{Q("TwoFactorEnabled")},{Q("AuthenticationGeneration")}) VALUES ('security-a',@True,4); INSERT INTO {Q("DepartmentSsoConfigs")} VALUES (709,@True),(709,@False),(710,@True); INSERT INTO {Q("DepartmentSecurityPolicies")} VALUES (709,@True,@False,30,2,90,12,@True,@False,@False,@False,@True,@True,@True,3)",args); await db.ExecuteAsync($"INSERT INTO {Q("UserSessions")} VALUES ('a','security-a',709,0,@Old,@Now,@Future,4),('b','security-a',709,0,@Old,@Now,@Future,3),('expired','security-a',709,0,@Old,@Old,@Old,4),('revoked','security-a',709,1,@Old,@Now,@Future,4),('disabled','security-disabled',709,0,@Old,@Now,@Future,4),('foreign','security-other',710,0,@Old,@Now,@Future,4)",args); using var unit = new UnitOfWork(Connections()); var repository = Repository(unit); var policy = await repository.ReadSecurityPolicyAsync(709, CancellationToken.None); - Assert.That(policy.RequireMfa, Is.True); Assert.That(policy.MinPasswordLength, Is.EqualTo(12)); Assert.That(await repository.ReadSecurityPolicyAsync(710, CancellationToken.None), Is.Null); + Assert.That(policy.RequireMfa, Is.True); Assert.That(policy.MinPasswordLength, Is.EqualTo(12)); + Assert.That(policy.AllowPasskeysForAdp, Is.False, "the method switches are read, not defaulted"); Assert.That(policy.MfaPolicyVersion, Is.EqualTo(3)); Assert.That(await repository.ReadSecurityPolicyAsync(710, CancellationToken.None), Is.Null); var evidence = await repository.ReadSecurityImpactAsync(709, now, 10, CancellationToken.None); Assert.That(evidence.Members.Count, Is.EqualTo(2)); Assert.That(evidence.Members[0].TwoFactorEnabled, Is.True); Assert.That(evidence.Members[1].TwoFactorEnabled, Is.Null); Assert.That(evidence.Sessions.Select(s => s.Id), Is.EqualTo(new[] { "a", "b" })); Assert.That(evidence.Sessions[1].CurrentGeneration, Is.EqualTo(4)); Assert.That(evidence.EnabledSsoProviders, Is.EqualTo(1)); diff --git a/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs index f2f376c71..e04605ab9 100644 --- a/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs +++ b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs @@ -65,7 +65,9 @@ public void Serialized_configuration_fields_have_independent_catalog_entries_inc [Test] public void Dedicated_configuration_tables_have_field_inventory_without_secret_values() { - var metadata = new[] { "DepartmentSecurityPolicyId", "DepartmentSsoConfigId", "DepartmentCallEmailId", "WeatherAlertZoneId", "Id", "DepartmentNotificationId", "DepartmentId", "CreatedOn", "UpdatedOn", "CreatedAt", "UpdatedAt", "CreatedByUserId", "UpdatedByUserId", "ReferringDepartmentId", "AffiliateCode" }.ToHashSet(); + // MfaPolicyVersion is advanced by the server with every sign-in MFA rule change; it is metadata, not a setting. So are + // the provider step-up mapping's version and test result, which only the server's guarded test flow writes. + var metadata = new[] { "MfaPolicyVersion", "FederatedMfaMappingVersion", "FederatedMfaTestedVersion", "FederatedMfaTestedOnUtc", "FederatedMfaTestedByUserId", "DepartmentSecurityPolicyId", "DepartmentSsoConfigId", "DepartmentCallEmailId", "WeatherAlertZoneId", "Id", "DepartmentNotificationId", "DepartmentId", "CreatedOn", "UpdatedOn", "CreatedAt", "UpdatedAt", "CreatedByUserId", "UpdatedByUserId", "ReferringDepartmentId", "AffiliateCode" }.ToHashSet(); foreach (var type in new[] { typeof(Department), typeof(DepartmentSecurityPolicy), typeof(DepartmentSsoConfig), typeof(DepartmentCallEmail), typeof(WeatherAlertZone), typeof(ChatbotDepartmentConfig), typeof(DepartmentNotification) }) { var fields = type.GetProperties().Where(p => p.CanWrite && p.GetCustomAttribute() == null && diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index 5e9ca24e7..cbca61447 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -90,6 +90,12 @@ public static void Initialize() // test about Records registrations. builder.RegisterInstance(new Moq.Mock().Object) .As(); + // ProtectedReadService checks the credential behind passkey, approval and provider step-up grants; loose mocks + // keep these composition tests about their own registrations. + builder.RegisterInstance(new Moq.Mock().Object).As().IfNotRegistered(typeof(IUserPasskeyRepository)); + builder.RegisterInstance(new Moq.Mock().Object).As().IfNotRegistered(typeof(IUserSessionsRepository)); + builder.RegisterInstance(new Moq.Mock().Object).As() + .IfNotRegistered(typeof(IDepartmentSsoConfigRepository)); builder.RegisterInstance(new Moq.Mock().Object) .As(); builder.RegisterInstance(new Moq.Mock().Object) diff --git a/Tests/Resgrid.Tests/Localization/NeutralResourcesFallbackTests.cs b/Tests/Resgrid.Tests/Localization/NeutralResourcesFallbackTests.cs new file mode 100644 index 000000000..b8a29f2dd --- /dev/null +++ b/Tests/Resgrid.Tests/Localization/NeutralResourcesFallbackTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Resources; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using NUnit.Framework; +using Resgrid.Localization; +using InventoryLabels = Resgrid.Localization.Areas.User.Inventory.Inventory; +using UnitsLabels = Resgrid.Localization.Areas.User.Units.Units; + +namespace Resgrid.Tests.Localization +{ + /// + /// The English satellite is the ultimate resource fallback for Resgrid.Localization. + /// + /// Most resource bases ship only X.en.resx and the translated files, with no neutral + /// X.resx. A lookup that falls through to the invariant culture then found nothing, and + /// IStringLocalizer returned the key name. The web app always sets a request culture, but the + /// API host never runs request localization, so its v4 controllers resolve strings in whatever culture + /// the process has (invariant in a container without LANG). InventoryController's ProblemDetails title + /// came back as "UnableToComplete". + /// + /// + /// [assembly: NeutralResourcesLanguage("en", UltimateResourceFallbackLocation.Satellite)] makes + /// the invariant culture resolve from the en satellite. The main-assembly neutral files are then never + /// read, so every string must live in X.en.resx; keeps the + /// translations aligned with it. + /// + /// + [TestFixture] + public class NeutralResourcesFallbackTests + { + private static readonly Assembly LocalizationAssembly = typeof(Common).Assembly; + private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en"); + + private static List ResourceBaseNames() + { + const string suffix = ".en.resources"; + return LocalizationAssembly.GetSatelliteAssembly(English) + .GetManifestResourceNames() + .Where(n => n.EndsWith(suffix, StringComparison.Ordinal)) + .Select(n => n.Substring(0, n.Length - suffix.Length)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + } + + [Test] + public void english_satellite_should_be_the_ultimate_fallback() + { + var attribute = LocalizationAssembly.GetCustomAttribute(); + + attribute.Should().NotBeNull("without it a lookup in the invariant culture finds no resources for bases lacking a neutral .resx"); + attribute!.CultureName.Should().Be("en"); + attribute.Location.Should().Be(UltimateResourceFallbackLocation.Satellite); + } + + [TestCase("")] // the API host and workers: no request culture + [TestCase("ja")] // a culture with no satellite at all + public void every_resource_base_should_resolve_its_english_text_when_no_translation_applies(string cultureName) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var bases = ResourceBaseNames(); + var failures = new List(); + + bases.Should().NotBeEmpty("the en satellite should carry the English resources"); + + foreach (var baseName in bases) + { + var manager = new ResourceManager(baseName, LocalizationAssembly); + var english = manager.GetResourceSet(English, true, false)! + .Cast() + .ToDictionary(e => (string)e.Key, e => e.Value as string); + + try + { + var wrong = english.Where(pair => manager.GetString(pair.Key, culture) != pair.Value).Select(pair => pair.Key).ToList(); + if (wrong.Count > 0) + failures.Add($"{baseName}: {wrong.Count} of {english.Count} keys differ from English, e.g. {wrong[0]}"); + } + catch (MissingManifestResourceException) + { + failures.Add($"{baseName}: no resources found ({english.Count} keys would render as their names)"); + } + } + + if (failures.Count > 0) + { + var message = new StringBuilder(); + message.AppendLine($"{failures.Count} of {bases.Count} resource bases do not fall back to English for culture '{cultureName}':"); + foreach (var failure in failures) + message.AppendLine(" " + failure); + + Assert.Fail(message.ToString()); + } + } + + [Test] + public void string_localizers_should_return_english_when_no_request_culture_is_set() + { + var previous = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + using var provider = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider(); + + // The API's InventoryController uses this for every ProblemDetails title. + var inventory = provider.GetRequiredService>()["UnableToComplete"]; + inventory.ResourceNotFound.Should().BeFalse(); + inventory.Value.Should().NotBe("UnableToComplete"); + + // Unit Tracking strings used to live only in the neutral Units.resx. + var units = provider.GetRequiredService>()["TrackingIdentifierRequired"]; + units.ResourceNotFound.Should().BeFalse(); + units.Value.Should().Be("A device identifier is required for the selected profile."); + } + finally { CultureInfo.CurrentUICulture = previous; } + } + } +} diff --git a/Tests/Resgrid.Tests/Localization/RazorOutputEncodingTests.cs b/Tests/Resgrid.Tests/Localization/RazorOutputEncodingTests.cs new file mode 100644 index 000000000..a793287ef --- /dev/null +++ b/Tests/Resgrid.Tests/Localization/RazorOutputEncodingTests.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Localization +{ + /// + /// Localized text placed inside JavaScript must be JavaScript-encoded, not HTML-encoded. + /// + /// In onclick="return confirm('@localizer["X"]');" Razor turns an apostrophe into &#x27;, + /// but the browser decodes entities in the attribute before compiling the handler. A French or Italian + /// value such as "l'élément" or "dell'utente" then ends the JS string early, the handler never compiles, + /// and the click goes ahead with no confirmation: the delete, void or cancel runs unasked. In a + /// <script> block, '@Html.Raw(localizer["X"].Value)' breaks the whole script the same way. + /// + /// + /// Plain '@localizer["X"]' in a <script> block compiles, but the browser does not decode + /// entities there. The JS string then holds l&#x27;&#xE9;l&#xE9;ment, and confirm(), .text(), + /// select2 placeholders, chart labels and values posted back to the server all show or store the entities. + /// + /// + /// The check is on the view, not the translations. Every localized value in an inline handler or a script + /// block has to go through JsEncoder.Encode (injected in Areas/User/Views/_ViewImports.cshtml) or + /// another JavaScript encoder, whether or not any locale contains an apostrophe today. Translations arrive + /// after the view is merged, and ASCII apostrophes are normal in them. The JS string then holds plain text, + /// and script that puts it into HTML escapes it there, as it does for user data. + /// + /// + /// Markup built as a string inside @Html.Raw(...) skips Razor's encoding altogether. Units/Index put + /// data-confirm='{localizer["DeleteUnitWarning"]} {u.Name}?' there, and Messages/Inbox did the same with + /// the message subject, so an apostrophe cut the prompt short and a subject containing markup was injected + /// into the recipient's inbox. Every value spliced into such a string must be encoded where it is spliced. + /// + /// + [TestFixture] + public class RazorOutputEncodingTests + { + private static readonly Regex LocalizerInject = new Regex(@"@inject\s+I(?:String|Html|View)Localizer(?:<[\w.]+>)?\s+(\w+)", RegexOptions.Compiled); + private static readonly Regex HandlerAttribute = new Regex(@"\son[a-z]+\s*=\s*""", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // A real opening tag starts its line or follows markup or a Razor brace. This skips comments that + // mention the tag, such as "JSON emitted inside ")] + public void javascript_encoded_text_should_pass_through_razor_html_encoding_unchanged(string text) + { + // The views write '@JsEncoder.Encode(x)' and let Razor HTML-encode the result. That only works because + // the JavaScript encoder never emits a character the HTML encoder would change, and emits no quote, + // backslash (other than its own escapes), line break or '<' that could end the string or the script. + var javaScript = JavaScriptEncoder.Default.Encode(text); + + HtmlEncoder.Default.Encode(javaScript).Should().Be(javaScript); + javaScript.Should().NotContainAny("'", "\"", "<", ">", "&", "\n", "\r"); + JsonSerializer.Deserialize("\"" + javaScript + "\"").Should().Be(text, "the JS string literal must decode back to the original text"); + } + } +} diff --git a/Tests/Resgrid.Tests/Localization/ResourceKeyParityTests.cs b/Tests/Resgrid.Tests/Localization/ResourceKeyParityTests.cs new file mode 100644 index 000000000..1fb4e6c45 --- /dev/null +++ b/Tests/Resgrid.Tests/Localization/ResourceKeyParityTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using NUnit.Framework; +using Resgrid.Localization; + +namespace Resgrid.Tests.Localization +{ + /// + /// Every translated resx must carry exactly the keys of its English sibling. + /// + /// Most resource bases have no neutral X.resx, only X.en.resx and the translated + /// files. For those, a key that exists in English but not in a locale has nothing to fall back to: + /// IStringLocalizer returns the key name itself, and a member reading that locale sees + /// "DeleteLogConfirm" on the page. That is how Security went months without PermDeleteLog* in most + /// locales. A missing locale file does the same for every key in the base. + /// + /// + /// Extra keys are reported too. A key present in a translation but not in English is either a + /// Visual Studio starter entry (Name1, Bitmap1, Icon1) or a key renamed or removed in English, + /// and either way it hides a missing translation of the current key. + /// + /// + /// Unlike , this does not check whether values are + /// translated. An English value under the right key renders as English; a missing key renders + /// as a raw identifier. + /// + /// + [TestFixture] + public class ResourceKeyParityTests + { + // Matches composite format items such as {0}, {1:f} and {2,-10}. Named tokens like + // {tenant-id} are literal text in the SSO help strings and are not format items. + private static readonly Regex FormatItem = new Regex(@"(?> Load(string path) + { + return XDocument.Load(path) + .Root! + .Elements("data") + .Select(x => new KeyValuePair( + (string)x.Attribute("name")!, + (string)x.Element("value") ?? string.Empty)) + .ToList(); + } + + private static string FormatItems(string value) + { + return string.Join(" ", FormatItem.Matches(value) + .Select(m => int.Parse(m.Groups[1].Value)) + .OrderBy(i => i) + .Select(i => "{" + i + "}")); + } + + private static IEnumerable EnglishResourceFiles(string root) + { + return Directory.EnumerateFiles(root, "*.en.resx", SearchOption.AllDirectories) + .Where(p => !p.Split(Path.DirectorySeparatorChar).Any(s => s == "bin" || s == "obj")) + .OrderBy(p => p, StringComparer.Ordinal); + } + + [Test] + public void every_locale_should_have_the_english_key_set_and_format_items() + { + var root = LocalizationRoot(); + var languages = SupportedLocales.GetSupportedCultures().Where(l => l != "en").OrderBy(l => l).ToList(); + var englishFiles = EnglishResourceFiles(root).ToList(); + var gaps = new List(); + + Assert.That(englishFiles, Is.Not.Empty, "no *.en.resx files were found under " + root); + + foreach (var englishPath in englishFiles) + { + var baseName = Path.GetRelativePath(root, englishPath) + .Replace(Path.DirectorySeparatorChar, '/'); + baseName = baseName.Substring(0, baseName.Length - ".en.resx".Length); + + var englishEntries = Load(englishPath); + var english = new Dictionary(StringComparer.Ordinal); + foreach (var duplicate in englishEntries.GroupBy(e => e.Key).Where(g => g.Count() > 1)) + gaps.Add($"{baseName}.en: duplicate key {duplicate.Key}"); + foreach (var entry in englishEntries) + english[entry.Key] = entry.Value; + + foreach (var language in languages) + { + var label = $"{baseName}.{language}"; + var path = Path.Combine(root, (baseName + "." + language + ".resx").Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(path)) + { + gaps.Add($"{label}: file missing ({english.Count} keys)"); + continue; + } + + var translatedEntries = Load(path); + var translated = new Dictionary(StringComparer.Ordinal); + foreach (var duplicate in translatedEntries.GroupBy(e => e.Key).Where(g => g.Count() > 1)) + gaps.Add($"{label}: duplicate key {duplicate.Key}"); + foreach (var entry in translatedEntries) + translated[entry.Key] = entry.Value; + + var missing = english.Keys.Where(k => !translated.ContainsKey(k)).ToList(); + if (missing.Count > 0) + gaps.Add($"{label}: missing {missing.Count}: {string.Join(", ", missing)}"); + + var extra = translated.Keys.Where(k => !english.ContainsKey(k)).ToList(); + if (extra.Count > 0) + gaps.Add($"{label}: extra {extra.Count}: {string.Join(", ", extra)}"); + + foreach (var pair in english) + { + if (!translated.TryGetValue(pair.Key, out var value)) + continue; + + var expected = FormatItems(pair.Value); + var actual = FormatItems(value); + if (expected != actual) + gaps.Add($"{label}: {pair.Key} has format items [{actual}], English has [{expected}]"); + } + } + } + + if (gaps.Count > 0) + { + var message = new StringBuilder(); + message.AppendLine($"{gaps.Count} resx parity gap(s) against *.en.resx. A missing key renders as its raw name in resource bases without a neutral .resx:"); + foreach (var gap in gaps) + message.AppendLine(" " + gap); + + Assert.Fail(message.ToString()); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index 405fa271a..7ed2a05e3 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -80,6 +80,7 @@ + diff --git a/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs b/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs index b39c58a8b..1d4891ba6 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsPermissionRowsTests.cs @@ -140,6 +140,49 @@ public void SecurityResx_CarriesLabelAndNoteForEveryRowInEveryLanguage() } } + [Test] + public void Build_WithLabels_UsesThemForEveryOptionText() + { + var labels = new PermissionOptionLabels + { + Everyone = "L3", + DepartmentAdmins = "L0", + DepartmentAndGroupAdmins = "L1", + DepartmentAdminsAndSelectRoles = "L2", + DepartmentGroupAdminsAndSelectRoles = "L4" + }; + + var rows = RecordsPermissionRows.Build(new List(), labels: labels); + + foreach (var row in rows) + row.Options.Should().OnlyContain(o => o.Text == "L" + o.Value, row.Name); + } + + [Test] + public void SecurityResx_CarriesEveryPermissionOptionLabelInEveryLanguage() + { + var root = LocalizationRoot(); + + foreach (var language in Languages) + { + var entries = Load(Path.Combine(root, "Areas", "User", "Security", $"Security.{language}.resx")); + + foreach (var key in PermissionOptionLabels.Keys) + { + entries.Should().ContainKey(key, $"{language} must carry {key}"); + entries[key].Should().NotBeNullOrWhiteSpace($"{language} {key}"); + } + } + + // The English fallback used without a localizer must say exactly what the English resources say. + var english = Load(Path.Combine(root, "Areas", "User", "Security", "Security.en.resx")); + english[PermissionOptionLabels.EveryoneKey].Should().Be(PermissionOptionLabels.English.Everyone); + english[PermissionOptionLabels.DepartmentAdminsKey].Should().Be(PermissionOptionLabels.English.DepartmentAdmins); + english[PermissionOptionLabels.DepartmentAndGroupAdminsKey].Should().Be(PermissionOptionLabels.English.DepartmentAndGroupAdmins); + english[PermissionOptionLabels.DepartmentAdminsAndSelectRolesKey].Should().Be(PermissionOptionLabels.English.DepartmentAdminsAndSelectRoles); + english[PermissionOptionLabels.DepartmentGroupAdminsAndSelectRolesKey].Should().Be(PermissionOptionLabels.English.DepartmentGroupAdminsAndSelectRoles); + } + private static string LocalizationRoot() { var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); diff --git a/Tests/Resgrid.Tests/Search/CallNotesProjectionTests.cs b/Tests/Resgrid.Tests/Search/CallNotesProjectionTests.cs new file mode 100644 index 000000000..70dd16538 --- /dev/null +++ b/Tests/Resgrid.Tests/Search/CallNotesProjectionTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Services.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// A call's dispatch notes, completion notes and call note rows are in its full text (schema 3), under the same + /// protection rule as the nature and address: only where Advanced Data Protection is not enforced, never an envelope. + /// + [TestFixture] + public class CallNotesProjectionTests + { + private Mock _callNotes; + private Mock _protection; + private SearchProjectionService _service; + private bool _enforced; + private List _rows; + + [SetUp] + public void SetUp() + { + _enforced = false; + _rows = new List + { + new CallNote { CallNoteId = 2, CallId = 42, Note = "

Keyholder on scene, panel shows trouble zone 3

", Timestamp = new DateTime(2026, 5, 1, 10, 5, 0) }, + new CallNote { CallNoteId = 1, CallId = 42, Note = "Alarm company: trouble alarm @ Bldg 4, room 12", Timestamp = new DateTime(2026, 5, 1, 10, 0, 0) }, + new CallNote { CallNoteId = 3, CallId = 42, Note = "Removed by a moderator", IsDeleted = true, Timestamp = new DateTime(2026, 5, 1, 10, 6, 0) }, + new CallNote { CallNoteId = 4, CallId = 42, Note = ProtectedDataEnvelope.Prefix + "AAAA", Timestamp = new DateTime(2026, 5, 1, 10, 7, 0) } + }; + _callNotes = new Mock(); + _callNotes.Setup(r => r.GetCallNotesByCallIdAsync(42)).ReturnsAsync(() => _rows); + _protection = new Mock(); + _protection.Setup(p => p.IsProtectionEnforcedAsync(It.IsAny())).ReturnsAsync(() => _enforced); + _service = new SearchProjectionService(Mock.Of(), _protection.Object, null, _callNotes.Object); + } + + private static Call Call() => new Call + { + CallId = 42, DepartmentId = 7, Number = "2026-000042", Name = "Commercial Alarm", NatureOfCall = "Alarm activation", Type = "Alarm", + Address = "1 Industrial Way", Notes = "ACME Monitoring account 5521.
Called in by operator 7.", CompletedNotes = "Reset by keyholder.", + State = (int)CallStates.Closed, LoggedOn = new DateTime(2026, 5, 1, 9, 58, 0) + }; + + [Test] + public async Task Notes_completed_notes_and_live_call_notes_are_in_the_full_text_oldest_first() + { + var p = await _service.BuildCallAsync(Call()); + + p.SearchText.Should().Contain("ACME Monitoring account 5521. Called in by operator 7.", "markup is stripped"); + p.SearchText.Should().Contain("Reset by keyholder."); + p.SearchText.Should().Contain("Alarm company: trouble alarm @ Bldg 4, room 12"); + p.SearchText.Should().Contain("Keyholder on scene, panel shows trouble zone 3"); + p.SearchText.IndexOf("trouble alarm", StringComparison.Ordinal).Should().BeLessThan(p.SearchText.IndexOf("panel shows", StringComparison.Ordinal)); + p.SearchText.Should().NotContain("Removed by a moderator", "deleted notes leave the projection"); + p.SearchText.Should().NotContain(ProtectedDataEnvelope.Prefix, "an envelope is never projected"); + } + + [Test] + public async Task The_repository_wins_over_a_partial_list_carried_on_the_call() + { + var call = Call(); + call.CallNotes = new List { new CallNote { CallId = 42, Note = "Only the note this request posted" } }; + + var p = await _service.BuildCallAsync(call); + + p.SearchText.Should().Contain("trouble alarm @ Bldg 4").And.NotContain("Only the note this request posted"); + } + + [Test] + public async Task Enforced_protection_keeps_every_note_out() + { + _enforced = true; + + var p = await _service.BuildCallAsync(Call()); + + p.SearchText.Should().BeNull(); + _callNotes.Verify(r => r.GetCallNotesByCallIdAsync(It.IsAny()), Times.Never, "the note rows are not even read when nothing can be projected"); + } + + [Test] + public async Task Without_the_repository_the_notes_loaded_on_the_call_are_used() + { + var service = new SearchProjectionService(Mock.Of(), _protection.Object); + var call = Call(); + call.CallNotes = _rows; + + var p = await service.BuildCallAsync(call); + + p.SearchText.Should().Contain("trouble alarm @ Bldg 4"); + } + + [Test] + public void Schema_three_rebuilds_every_department_for_the_new_full_text() + { + GlobalSearchGeneration.SchemaVersion.Should().BeGreaterThanOrEqualTo(3); + GlobalSearchGeneration.Compute(0, 0).Should().StartWith(GlobalSearchGeneration.SchemaVersion + "."); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/GlobalSearchFullTextTests.cs b/Tests/Resgrid.Tests/Search/GlobalSearchFullTextTests.cs new file mode 100644 index 000000000..b5b46094a --- /dev/null +++ b/Tests/Resgrid.Tests/Search/GlobalSearchFullTextTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Lucene.Net.Store; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// Full-text search over the global index: call notes in the full text, quoted phrases, the mid-word stub over the full + /// text, date ranges, date orderings and the deeper window the search page uses for a narrowed question. + /// + [TestFixture] + public class GlobalSearchFullTextTests + { + private const string Generation = "3.0.0"; + private LuceneGlobalIndexHost _host; + private LuceneGlobalSearchIndexer _indexer; + private LuceneGlobalSearchService _search; + private int _maxResults; + private int _maxPageWindow; + + [SetUp] + public async Task SetUp() + { + SearchConfig.Enabled = true; + _maxResults = SearchConfig.MaxResults; + _maxPageWindow = SearchConfig.MaxPageWindow; + _host = new LuceneGlobalIndexHost(new RAMDirectory(), ownsDirectory: true); + _indexer = new LuceneGlobalSearchIndexer(_host); + _search = new LuceneGlobalSearchService(_host); + + await _indexer.IndexAsync(new[] + { + Call("101", "Alarm - Commercial", "ACME Monitoring reports trouble alarm @ Bldg 4, room 12. Keyholder en route.", new DateTime(2026, 3, 1)), + Call("102", "Alarm - Commercial", "ACME Monitoring: trouble alarm at Bldg 4 room 12 again, second time this week.", new DateTime(2026, 4, 1)), + Call("103", "Alarm - Commercial", "Fire alarm activation Bldg 7. Alarm company reports trouble on the panel only.", new DateTime(2026, 5, 1)), + Call("104", "Medical", "Patient fell in room 12 of the clinic.", new DateTime(2026, 6, 1)) + }, Generation); + await _indexer.CommitAsync(); + } + + [TearDown] + public void TearDown() + { + _host.Dispose(); + SearchConfig.Enabled = false; + SearchConfig.MaxResults = _maxResults; + SearchConfig.MaxPageWindow = _maxPageWindow; + } + + private static SearchProjection Call(string id, string title, string notes, DateTime occurred) + { + var p = GlobalSearchTests.Projection(1, SearchEntityTypes.Call, id, title, keywords: "2026-000" + id, summary: title, occurred: occurred); + p.SearchText = notes; + return p; + } + + private Task Find(string text, Action configure = null) + { + var query = new GlobalSearchQuery { Text = text, ViewerUserId = "u1", Generation = Generation, Take = 50 }; + configure?.Invoke(query); + return _search.SearchAsync(1, query); + } + + [Test] + public async Task Words_found_only_in_the_notes_match_the_call() + { + var result = await Find("keyholder"); + + result.Hits.Select(h => h.EntityId).Should().Equal("101"); + } + + [Test] + public async Task Every_word_must_match_somewhere() + { + var result = await Find("trouble alarm bldg 4 room 12"); + + result.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "101", "102" }); + } + + [Test] + public async Task A_quoted_phrase_matches_the_words_in_order_only() + { + var phrase = await Find("\"trouble alarm\""); + var loose = await Find("trouble alarm"); + + phrase.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "101", "102" }, "call 103 has both words but not as a phrase"); + loose.Hits.Select(h => h.EntityId).Should().Contain("103"); + } + + [Test] + public async Task Phrases_and_words_combine_and_punctuation_in_the_text_does_not_break_a_phrase() + { + var result = await Find("\"trouble alarm\" \"bldg 4\" \"room 12\""); + + result.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "101", "102" }, "'@ Bldg 4, room 12' and 'at Bldg 4 room 12' both hold the phrases"); + result.Total.Should().Be(2); + } + + [Test] + public async Task An_unmatched_quote_is_ignored_rather_than_failing_the_query() + { + var result = await Find("\"trouble alarm bldg"); + + result.Available.Should().BeTrue(); + result.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "101", "102", "103" }, "the stray quote is dropped and the words match loosely"); + } + + [Test] + public async Task A_partial_last_word_matches_the_notes_once_it_is_long_enough() + { + var stub = await Find("acme monit"); + var tooShort = await Find("acme mo"); + + stub.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "101", "102" }); + tooShort.Hits.Should().BeEmpty("a two-letter stub is only expanded against titles and identifiers"); + } + + [Test] + public async Task The_date_range_bounds_when_the_hit_occurred() + { + var march = await Find("alarm", q => { q.FromUtc = new DateTime(2026, 3, 1); q.ToUtc = new DateTime(2026, 3, 31, 23, 59, 59); }); + var fromApril = await Find("alarm", q => q.FromUtc = new DateTime(2026, 4, 1)); + var untilMarch = await Find("alarm", q => q.ToUtc = new DateTime(2026, 3, 15)); + + march.Hits.Select(h => h.EntityId).Should().Equal("101"); + fromApril.Hits.Select(h => h.EntityId).Should().BeEquivalentTo(new[] { "102", "103" }); + untilMarch.Hits.Select(h => h.EntityId).Should().Equal("101"); + march.Total.Should().Be(1, "the range is applied before counting"); + } + + [Test] + public async Task Newest_and_oldest_order_by_when_the_hit_occurred() + { + var newest = await Find("alarm", q => q.Sort = SearchSortOrders.Newest); + var oldest = await Find("alarm", q => q.Sort = SearchSortOrders.Oldest); + + newest.Hits.Select(h => h.EntityId).Should().Equal("103", "102", "101"); + oldest.Hits.Select(h => h.EntityId).Should().Equal("101", "102", "103"); + } + + [Test] + public async Task A_deeper_window_is_honoured_up_to_the_page_window_cap() + { + SearchConfig.MaxResults = 2; + SearchConfig.MaxPageWindow = 3; + + var typeahead = await Find("alarm", q => q.Take = 10); + var page = await Find("alarm", q => { q.Take = 10; q.MaxWindow = 10; }); + + typeahead.Hits.Should().HaveCount(2); + typeahead.Truncated.Should().BeTrue(); + page.Hits.Should().HaveCount(3, "the page window cap (3) wins over the requested 10"); + page.Truncated.Should().BeFalse("all three matches fit the window"); + } + + [Test] + public async Task Indexed_row_versions_report_what_the_index_holds() + { + var indexed = Call("105", "Alarm", "Indexed once", new DateTime(2026, 7, 1)); + indexed.RowVersion = 7; + await _indexer.IndexAsync(new[] { indexed }, Generation); + await _indexer.CommitAsync(); + var missing = Call("106", "Alarm", "Never indexed", new DateTime(2026, 7, 2)); + + var versions = await _indexer.GetIndexedRowVersionsAsync(1, new[] { indexed, missing }); + + versions.Should().ContainKey(indexed.SearchProjectionId).WhoseValue.Should().Be(7); + versions.Should().NotContainKey(missing.SearchProjectionId); + } + + [Test] + public void Clauses_keep_quoted_phrases_together_and_drop_stop_words() + { + var clauses = LuceneGlobalSearchService.ParseClauses("\"trouble alarm\" the bldg-4"); + + clauses.Should().HaveCount(2); + clauses[0].Quoted.Should().BeTrue(); + clauses[0].Tokens.Select(t => t.Text).Should().Equal("trouble", "alarm"); + clauses[1].Quoted.Should().BeFalse(); + clauses[1].Tokens.Select(t => t.Text).Should().Equal("bldg", "4"); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SearchCoverageProjectionTests.cs b/Tests/Resgrid.Tests/Search/SearchCoverageProjectionTests.cs new file mode 100644 index 000000000..68ed55c2b --- /dev/null +++ b/Tests/Resgrid.Tests/Search/SearchCoverageProjectionTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Services.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// The coverage pass: related rows that enrich the existing families (caller, custom fields, station and role names, + /// member identification numbers, contact addresses and notes) and the operations reference families (protocols, + /// trainings, calendar events, logs, POIs, shifts, groups, occupancies), each under the protected-text rule. + /// + [TestFixture] + public class SearchCoverageProjectionTests + { + private const int Dept = 7; + private bool _enforced; + private Mock _protection; + private Mock _projections; + private Mock _addresses; + private Mock _contactNotes; + private Mock _categories; + private Mock _roles; + private Mock _groupMembers; + private Mock _groups; + private Mock _sensitive; + private Mock _udfDefinitions; + private Mock _udfFields; + private Mock _udfValues; + private Mock _calls; + private Mock _profiles; + private Mock _members; + private Mock _hazards; + private List _upserts; + private List<(string Type, string Id)> _removed; + private SearchProjectionService _service; + + [SetUp] + public void SetUp() + { + _enforced = false; + _upserts = new List(); + _removed = new List<(string, string)>(); + _protection = new Mock(); + _protection.Setup(p => p.IsProtectionEnforcedAsync(It.IsAny())).ReturnsAsync(() => _enforced); + _projections = new Mock(); + _projections.Setup(r => r.UpsertAsync(It.IsAny(), It.IsAny())) + .Callback((SearchProjection p, CancellationToken _) => _upserts.Add(p)).ReturnsAsync((SearchProjection p, CancellationToken _) => p); + _projections.Setup(r => r.SoftDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((int _, string type, string id, CancellationToken __) => _removed.Add((type, id))).ReturnsAsync(true); + + _addresses = new Mock(); + _addresses.Setup(a => a.GetByIdAsync(11)).ReturnsAsync(new Address { AddressId = 11, Address1 = "400 Industrial Way", City = "Springfield", State = "IL", PostalCode = "62701" }); + _contactNotes = new Mock(); + _contactNotes.Setup(n => n.GetContactNotesByContactIdAsync("c1")).ReturnsAsync(new List + { + new ContactNote { ContactId = "c1", DepartmentId = Dept, Note = "Alarm panel in the east stairwell", AddedOn = new DateTime(2026, 1, 1) }, + new ContactNote { ContactId = "c1", DepartmentId = Dept, Note = "Old key holder list", IsDeleted = true, AddedOn = new DateTime(2025, 1, 1) } + }); + _categories = new Mock(); + _categories.Setup(c => c.GetByIdAsync("cat1")).ReturnsAsync(new ContactCategory { ContactCategoryId = "cat1", DepartmentId = Dept, Name = "Alarm Companies" }); + _roles = new Mock(); + _roles.Setup(r => r.GetRolesForUserAsync(Dept, "u1")).ReturnsAsync(new List + { + new PersonnelRole { DepartmentId = Dept, Name = "Engineer" }, new PersonnelRole { DepartmentId = Dept, Name = "Captain" }, new PersonnelRole { DepartmentId = 99, Name = "Elsewhere" } + }); + _groupMembers = new Mock(); + _groups = new Mock(); + _groups.Setup(g => g.GetGroupByGroupIdAsync(4)).ReturnsAsync(new DepartmentGroup { DepartmentGroupId = 4, DepartmentId = Dept, Name = "Station 4" }); + _sensitive = new Mock(); + _sensitive.Setup(s => s.GetByDepartmentAndUserAsync(Dept, "u1")).ReturnsAsync(new DepartmentMemberSensitiveData { DepartmentId = Dept, UserId = "u1", IdentificationNumber = "FF-1042" }); + _udfDefinitions = new Mock(); + _udfDefinitions.Setup(d => d.GetActiveDefinitionByDepartmentAndEntityTypeAsync(Dept, (int)UdfEntityType.Call)).ReturnsAsync(new UdfDefinition { UdfDefinitionId = "def-call" }); + _udfFields = new Mock(); + _udfFields.Setup(f => f.GetFieldsByDefinitionIdAsync("def-call")).ReturnsAsync(new List + { + new UdfField { UdfFieldId = "f1", Label = "Alarm Account", IsEnabled = true, Visibility = (int)UdfFieldVisibility.Everyone, Sensitivity = (int)UdfFieldSensitivity.None, SortOrder = 1 }, + new UdfField { UdfFieldId = "f2", Label = "Admin Memo", IsEnabled = true, Visibility = (int)UdfFieldVisibility.DepartmentAdminsOnly, SortOrder = 2 }, + new UdfField { UdfFieldId = "f3", Label = "Restricted", IsEnabled = true, Visibility = (int)UdfFieldVisibility.Everyone, Sensitivity = (int)UdfFieldSensitivity.Restricted, SortOrder = 3 } + }); + _udfValues = new Mock(); + _udfValues.Setup(v => v.GetFieldValuesByEntityAsync((int)UdfEntityType.Call, "42", "def-call")).ReturnsAsync(new List + { + new UdfFieldValue { UdfFieldId = "f1", Value = "ACME-5521" }, new UdfFieldValue { UdfFieldId = "f2", Value = "do not index this" }, new UdfFieldValue { UdfFieldId = "f3", Value = "nor this" } + }); + _calls = new Mock(); + _profiles = new Mock(); + _members = new Mock(); + _hazards = new Mock(); + + _service = new SearchProjectionService(_projections.Object, _protection.Object, null, null, _addresses.Object, _contactNotes.Object, + _categories.Object, _roles.Object, _groupMembers.Object, _groups.Object, _sensitive.Object, _udfDefinitions.Object, _udfFields.Object, + _udfValues.Object, _calls.Object, null, null, _profiles.Object, _members.Object, null, null, _hazards.Object); + } + + private static Call Call() => new Call + { + CallId = 42, DepartmentId = Dept, Number = "2026-000042", Name = "Commercial Alarm", NatureOfCall = "Trouble alarm", Type = "Alarm", + ContactName = "ACME Monitoring", ContactNumber = "(555) 010-2233", LoggedOn = new DateTime(2026, 5, 1) + }; + + [Test] + public async Task A_call_carries_its_caller_and_the_custom_fields_every_viewer_may_see() + { + var p = await _service.BuildCallAsync(Call()); + + p.SearchText.Should().Contain("ACME Monitoring").And.Contain("(555) 010-2233").And.Contain("Alarm Account ACME-5521"); + p.SearchText.Should().NotContain("do not index this", "an admin-only field never enters the shared index"); + p.SearchText.Should().NotContain("nor this", "a restricted field never enters the shared index"); + p.Keywords.Should().Contain("5550102233", "the caller's number is searchable by its digits"); + } + + [Test] + public async Task Enforced_protection_keeps_the_caller_and_custom_fields_out() + { + _enforced = true; + + var p = await _service.BuildCallAsync(Call()); + + (p.SearchText ?? string.Empty).Should().NotContain("ACME"); + p.Keywords.Should().Be("2026-000042"); + _udfValues.Verify(v => v.GetFieldValuesByEntityAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task A_member_carries_the_department_identification_number_group_and_roles() + { + var profile = new UserProfile { UserId = "u1", FirstName = "Kelly", LastName = "Severide", IdentificationNumber = "legacy-global" }; + + var p = await _service.BuildPersonnelAsync(Dept, profile, 4, true); + + p.Keywords.Should().Contain("FF-1042").And.NotContain("legacy-global", "the per-department row wins over the unmapped legacy column"); + p.Summary.Should().Be("Station 4 · Captain, Engineer"); + p.SearchText.Should().Contain("Station 4").And.Contain("Engineer").And.NotContain("Elsewhere"); + } + + [Test] + public async Task A_unit_carries_its_station_name() + { + var p = await _service.BuildUnitAsync(new Unit { UnitId = 5, DepartmentId = Dept, Name = "Engine 4", Type = "Engine", StationGroupId = 4 }); + + p.Summary.Should().Be("Engine · Station 4"); + p.SearchText.Should().Contain("Station 4"); + } + + [Test] + public async Task A_contact_carries_its_address_live_notes_and_category() + { + var p = await _service.BuildContactAsync(new Contact + { + ContactId = "c1", DepartmentId = Dept, ContactType = 1, CompanyName = "ACME Monitoring", PhysicalAddressId = 11, ContactCategoryId = "cat1" + }); + + p.SearchText.Should().Contain("400 Industrial Way Springfield IL 62701").And.Contain("Alarm panel in the east stairwell").And.Contain("Alarm Companies"); + p.SearchText.Should().NotContain("Old key holder list", "deleted notes leave the projection"); + p.Summary.Should().Contain("400 Industrial Way"); + } + + [Test] + public async Task Refreshing_a_member_who_was_disabled_removes_the_projection() + { + _members.Setup(m => m.GetDepartmentMemberByDepartmentIdAndUserIdAsync(Dept, "u1")).ReturnsAsync(new DepartmentMember { DepartmentId = Dept, UserId = "u1", IsDisabled = true }); + + await _service.RefreshAsync(Dept, SearchEntityTypes.Personnel, "u1"); + + _removed.Should().Contain((SearchEntityTypes.Personnel, "u1")); + _upserts.Should().BeEmpty(); + } + + [Test] + public async Task Refreshing_a_member_reads_the_current_group_and_rewrites_the_projection() + { + _members.Setup(m => m.GetDepartmentMemberByDepartmentIdAndUserIdAsync(Dept, "u1")).ReturnsAsync(new DepartmentMember { DepartmentId = Dept, UserId = "u1", IsActive = true }); + _profiles.Setup(p => p.GetProfileByUserIdAsync("u1")).ReturnsAsync(new UserProfile { UserId = "u1", FirstName = "Kelly", LastName = "Severide" }); + _groupMembers.Setup(g => g.GetAllGroupMembersByUserAndDepartmentAsync("u1", Dept)).ReturnsAsync(new List { new DepartmentGroupMember { DepartmentId = Dept, DepartmentGroupId = 4, UserId = "u1" } }); + + await _service.RefreshAsync(Dept, SearchEntityTypes.Personnel, "u1"); + + _upserts.Should().ContainSingle(p => p.EntityType == SearchEntityTypes.Personnel && p.GroupId == 4 && p.Summary.StartsWith("Station 4")); + } + + [Test] + public async Task Refreshing_a_call_from_another_department_does_nothing() + { + _calls.Setup(c => c.GetByIdAsync(42)).ReturnsAsync(new Call { CallId = 42, DepartmentId = 99, Name = "Elsewhere" }); + + await _service.RefreshAsync(Dept, SearchEntityTypes.Call, "42"); + + _upserts.Should().BeEmpty(); + } + + [Test] + public async Task A_log_carries_its_narrative_only_where_protection_allows_it() + { + var log = new Log + { + LogId = 9, DepartmentId = Dept, LogType = (int)LogTypes.Training, Course = "Ladder Operations", CourseCode = "LAD-2", + Instructors = "Capt. Casey", Narrative = "

Raised the 35 ft ladder at Bldg 4

", LoggedOn = new DateTime(2026, 2, 2), StationGroupId = 4 + }; + + var open = await _service.BuildLogAsync(log); + _enforced = true; + var closed = await _service.BuildLogAsync(log); + + open.Title.Should().Be("Training · Ladder Operations"); + open.SearchText.Should().Contain("Raised the 35 ft ladder at Bldg 4").And.Contain("Capt. Casey"); + open.Keywords.Should().Contain("LAD-2"); + open.Url.Should().Be("/User/Logs/View?logId=9"); + closed.SearchText.Should().Contain("Ladder Operations").And.NotContain("Raised the 35 ft ladder", "the narrative is cataloged"); + } + + [Test] + public async Task A_calendar_event_is_indexed_once_per_series_and_never_under_enforced_protection() + { + var parent = new CalendarItem { CalendarItemId = 3, DepartmentId = Dept, Title = "Monthly business meeting", Location = "Station 4", Start = new DateTime(2026, 6, 1), End = new DateTime(2026, 6, 1, 2, 0, 0), RecurrenceType = 2 }; + var occurrence = new CalendarItem { CalendarItemId = 4, DepartmentId = Dept, Title = "Monthly business meeting", RecurrenceId = "3", Start = new DateTime(2026, 7, 1), End = new DateTime(2026, 7, 1, 2, 0, 0) }; + + (await _service.BuildCalendarItemAsync(parent)).Title.Should().Be("Monthly business meeting"); + (await _service.BuildCalendarItemAsync(occurrence)).Should().BeNull("occurrences repeat the parent's text"); + _enforced = true; + (await _service.BuildCalendarItemAsync(parent)).Should().BeNull("calendar titles, descriptions and locations are cataloged"); + } + + [Test] + public async Task Reference_families_project_names_codes_and_their_own_pages() + { + var protocol = await _service.BuildProtocolAsync(new DispatchProtocol { DispatchProtocolId = 1, DepartmentId = Dept, Name = "Commercial Fire Alarm", Code = "CFA", ProtocolText = "Stage until keyholder arrives" }); + var training = await _service.BuildTrainingAsync(new Training { TrainingId = 2, DepartmentId = Dept, Name = "Hazmat Awareness", TrainingText = "Placard identification" }); + var shift = await _service.BuildShiftAsync(new Shift { ShiftId = 3, DepartmentId = Dept, Name = "B Shift", Code = "B", StartTime = "07:00", EndTime = "07:00" }); + var group = await _service.BuildGroupAsync(new DepartmentGroup { DepartmentGroupId = 4, DepartmentId = Dept, Name = "Station 4", Type = (int)DepartmentGroupTypes.Station, AddressId = 11 }); + var poi = await _service.BuildPoiAsync(new Poi { PoiId = 5, PoiTypeId = 6, Name = "Dry hydrant", Address = "Route 9", Note = "Pond access" }, new PoiType { PoiTypeId = 6, DepartmentId = Dept, Name = "Water Sources" }); + + protocol.Keywords.Should().Be("CFA"); + protocol.SearchText.Should().Contain("Stage until keyholder arrives"); + protocol.Url.Should().Be("/User/Protocols/View?id=1"); + training.SearchText.Should().Contain("Placard identification"); + training.Url.Should().Be("/User/Trainings/View?trainingId=2"); + shift.Summary.Should().Be("B · 07:00–07:00"); + group.Category.Should().Be("Station"); + group.SearchText.Should().Contain("400 Industrial Way"); + poi.DepartmentId.Should().Be(Dept, "a POI takes its department from its type"); + poi.SearchText.Should().Contain("Water Sources").And.Contain("Pond access"); + poi.Url.Should().Be("/User/Mapping/EditPOI?poiId=5"); + } + + [Test] + public async Task A_poi_whose_type_does_not_match_is_not_projected() + { + (await _service.BuildPoiAsync(new Poi { PoiId = 5, PoiTypeId = 6, Name = "Dry hydrant" }, new PoiType { PoiTypeId = 7, DepartmentId = Dept, Name = "Other" })).Should().BeNull(); + } + + [Test] + public async Task An_occupancy_carries_its_address_alarm_company_and_hazards_but_never_access_codes() + { + _hazards.Setup(h => h.GetForOccupancyAsync(Dept, "o1")).ReturnsAsync(new List + { + new RmsOccupancyHazard { Title = "Ammonia refrigeration" }, new RmsOccupancyHazard { Title = "Removed hazard", DeletedOn = DateTime.UtcNow } + }); + var occupancy = new RmsOccupancy + { + RmsOccupancyId = "o1", DepartmentId = Dept, Name = "Springfield Cold Storage", OccupancyNumber = "OCC-2026-0007", AddressText = "400 Industrial Way", + City = "Springfield", Status = (int)RmsOccupancyStatus.Active, AlarmCompany = "ACME Monitoring", AlarmCompanyPhone = "555-010-2233", + GateCode = "4471", KnoxBoxLocation = "Left of the main door", EmergencyContactPhone = "555-999-0000", ModifiedOn = new DateTime(2026, 3, 3) + }; + + var p = await _service.BuildOccupancyAsync(occupancy); + + p.Keywords.Should().Contain("OCC-2026-0007").And.Contain("5550102233"); + p.SearchText.Should().Contain("400 Industrial Way").And.Contain("ACME Monitoring").And.Contain("Ammonia refrigeration").And.NotContain("Removed hazard"); + string.Join(" ", p.Title, p.Summary, p.Keywords, p.SearchText).Should().NotContain("4471").And.NotContain("Left of the main door").And.NotContain("5559990000"); + p.Url.Should().Be("/User/RecordOccupancies/Details?id=o1"); + + occupancy.Status = (int)RmsOccupancyStatus.Merged; + (await _service.BuildOccupancyAsync(occupancy)).Should().BeNull("a merged occupancy is found through its survivor"); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SearchIndexSweepTests.cs b/Tests/Resgrid.Tests/Search/SearchIndexSweepTests.cs new file mode 100644 index 000000000..bee0c8225 --- /dev/null +++ b/Tests/Resgrid.Tests/Search/SearchIndexSweepTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Services.Search; + +namespace Resgrid.Tests.Search +{ + /// + /// Worker 70's catch-up sweep: one commit for the whole sweep with checkpoints saved only after it, rows its overlap + /// window re-reads unchanged are skipped (a quiet sweep commits nothing), and a sweep that overlaps a running one skips. + /// + [TestFixture] + public class SearchIndexSweepTests + { + private static readonly DateTime Checkpoint = new DateTime(2026, 10, 2, 7, 0, 0, DateTimeKind.Utc); + private Mock _states; + private Mock _projections; + private Mock _indexer; + private Mock _flags; + private List _rows; + private Dictionary> _changes; + private Dictionary _indexed; + private List _calls; + private SearchIndexMaintenanceService _service; + + [SetUp] + public void SetUp() + { + SearchConfig.Enabled = true; + _calls = new List(); + _rows = new List { State(1), State(2) }; + _changes = new Dictionary> { [1] = new List(), [2] = new List() }; + _indexed = new Dictionary(); + + _states = new Mock(); + _states.Setup(s => s.GetAllForIndexAsync(SearchIndexNames.Global)).ReturnsAsync(() => _rows); + _states.Setup(s => s.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((SearchIndexState s, CancellationToken _, bool __) => _calls.Add("save:" + s.DepartmentId)) + .ReturnsAsync((SearchIndexState s, CancellationToken _, bool __) => s); + + _projections = new Mock(); + _projections.Setup(p => p.GetModifiedSinceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int department, DateTime? since, int take, string sinceId) => sinceId == null + ? _changes[department].Where(r => !since.HasValue || r.ModifiedOn > since.Value).ToList() + : new List()); + + _indexer = new Mock(); + _indexer.Setup(i => i.IndexAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback((IEnumerable rows, string _, CancellationToken __) => _calls.Add("index:" + string.Join(",", rows.Select(r => r.EntityId)))) + .ReturnsAsync((IEnumerable rows, string _, CancellationToken __) => rows.Count()); + _indexer.Setup(i => i.DeleteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((int _, string __, string id, CancellationToken ___) => _calls.Add("delete:" + id)).Returns(Task.CompletedTask); + _indexer.Setup(i => i.CommitAsync(It.IsAny())).Callback(() => _calls.Add("commit")).Returns(Task.CompletedTask); + _indexer.Setup(i => i.CountDocumentsAsync(It.IsAny())).ReturnsAsync(10); + _indexer.Setup(i => i.GetIndexedRowVersionsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((int _, IEnumerable rows) => (IDictionary)rows + .Where(r => _indexed.ContainsKey(r.SearchProjectionId)).ToDictionary(r => r.SearchProjectionId, r => _indexed[r.SearchProjectionId])); + + _flags = new Mock(); + _flags.Setup(f => f.IsEnabledAsync(FeatureFlagKeys.SearchUnified, It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync(true); + + _service = new SearchIndexMaintenanceService(_states.Object, _projections.Object, _indexer.Object, Mock.Of(), + _flags.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of()); + } + + [TearDown] + public void TearDown() => SearchConfig.Enabled = false; + + private static SearchIndexState State(int department) => new SearchIndexState + { + IndexName = SearchIndexNames.Global, + DepartmentId = department, + State = (int)SearchIndexBuildState.Ready, + Generation = GlobalSearchGeneration.Compute(0, 0), + DocumentCount = 10, + LastIndexedModifiedOn = Checkpoint + }; + + private static SearchProjection Row(int department, string id, DateTime modified, long version = 1, bool deleted = false) => new SearchProjection + { + SearchProjectionId = "p-" + id, + DepartmentId = department, + EntityType = SearchEntityTypes.Call, + EntityId = id, + RowVersion = version, + ModifiedOn = modified, + DeletedOn = deleted ? modified : (DateTime?)null + }; + + [Test] + public async Task Changes_in_several_departments_commit_once_and_checkpoints_follow_the_commit() + { + _changes[1].Add(Row(1, "10", Checkpoint.AddMinutes(1))); + _changes[2].Add(Row(2, "20", Checkpoint.AddMinutes(2))); + + var result = await _service.SweepAsync(); + + result.Errors.Should().Be(0); + _calls.Count(c => c == "commit").Should().Be(1, "one publish per sweep, not one per department"); + _calls.IndexOf("commit").Should().BeLessThan(_calls.IndexOf("save:1")); + _calls.IndexOf("commit").Should().BeLessThan(_calls.IndexOf("save:2")); + _rows[0].LastIndexedModifiedOn.Should().Be(Checkpoint.AddMinutes(1)); + _rows[1].LastIndexedModifiedOn.Should().Be(Checkpoint.AddMinutes(2)); + result.DocumentsIndexed.Should().Be(2); + } + + [Test] + public async Task A_quiet_sweep_commits_nothing_when_the_overlap_window_only_re_reads_indexed_rows() + { + // The read re-covers the second before the checkpoint; this row is already indexed at the same version. + var reRead = Row(1, "10", Checkpoint.AddMilliseconds(-200), version: 4); + _changes[1].Add(reRead); + _indexed[reRead.SearchProjectionId] = 4; + + var result = await _service.SweepAsync(); + + result.Errors.Should().Be(0); + _calls.Should().BeEmpty("nothing changed: no index write, no commit, no publish, no state write"); + } + + [Test] + public async Task An_overlap_row_the_index_lacks_or_holds_at_an_older_version_is_still_indexed() + { + var late = Row(1, "10", Checkpoint.AddMilliseconds(-300), version: 1); + var updated = Row(1, "11", Checkpoint, version: 3); + _changes[1].AddRange(new[] { late, updated }); + _indexed[updated.SearchProjectionId] = 2; + + await _service.SweepAsync(); + + _calls.Should().Contain("index:10,11"); + _calls.Should().Contain("commit"); + _calls.Should().Contain("save:1"); + } + + [Test] + public async Task A_deleted_overlap_row_already_gone_from_the_index_is_not_deleted_again() + { + _changes[1].Add(Row(1, "10", Checkpoint.AddMilliseconds(-100), deleted: true)); + + await _service.SweepAsync(); + + _calls.Should().BeEmpty(); + } + + [Test] + public async Task A_failed_commit_advances_no_checkpoint() + { + _changes[1].Add(Row(1, "10", Checkpoint.AddMinutes(1))); + _indexer.Setup(i => i.CommitAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("publish lease held")); + + var result = await _service.SweepAsync(); + + result.Errors.Should().Be(1); + _calls.Should().NotContain(c => c.StartsWith("save:")); + _rows[0].LastIndexedModifiedOn.Should().Be(Checkpoint, "the next sweep re-reads from the stored checkpoint"); + } + + [Test] + public async Task A_sweep_that_overlaps_a_running_one_skips_instead_of_sharing_the_writer() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _states.Setup(s => s.GetAllForIndexAsync(SearchIndexNames.Global)).Returns(async () => + { + entered.TrySetResult(true); + await release.Task; + return (IEnumerable)new List(); + }); + + var first = _service.SweepAsync(); + await entered.Task; + var second = await _service.SweepAsync(); + release.SetResult(true); + var firstResult = await first; + + second.Skipped.Should().BeTrue(); + firstResult.Skipped.Should().BeFalse(); + (await _service.SweepAsync()).Skipped.Should().BeFalse("the gate is released when the running sweep ends"); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SearchProjectionHookTests.cs b/Tests/Resgrid.Tests/Search/SearchProjectionHookTests.cs new file mode 100644 index 000000000..78648ab32 --- /dev/null +++ b/Tests/Resgrid.Tests/Search/SearchProjectionHookTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Search; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Search +{ + /// + /// Writes that change a projected related row (a contact note, role membership) refresh the owning entity's search + /// projection, so the catch-up sweep re-indexes it rather than waiting for a full rebuild. + /// + [TestFixture] + public class SearchProjectionHookTests + { + private const int Dept = 7; + private Mock _search; + private List<(int Department, string Type, string Id)> _refreshed; + + [SetUp] + public void SetUp() + { + _refreshed = new List<(int, string, string)>(); + _search = new Mock(); + _search.Setup(s => s.RefreshAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((int d, string t, string id, CancellationToken _) => _refreshed.Add((d, t, id))).Returns(Task.CompletedTask); + } + + private ContactsService Contacts(Mock notes, Mock types = null) + { + var writes = new Mock(); + writes.Setup(w => w.PrepareContactNoteWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + return new ContactsService(Mock.Of(), notes.Object, Mock.Of(), (types ?? new Mock()).Object, + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + new Lazy(() => writes.Object), new Lazy(() => Mock.Of()), + new Lazy(() => _search.Object)); + } + + [Test] + public async Task Saving_a_contact_note_refreshes_the_contact() + { + var notes = new Mock(); + notes.Setup(n => n.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((ContactNote n, CancellationToken _, bool __) => n); + + await Contacts(notes).SaveContactNoteAsync(new ContactNote { ContactNoteId = "n1", ContactId = "c1", DepartmentId = Dept, Note = "Alarm panel moved" }); + + _refreshed.Should().Equal((Dept, SearchEntityTypes.Contact, "c1")); + } + + [Test] + public async Task Reading_a_contacts_notes_leaves_out_the_deleted_ones() + { + var notes = new Mock(); + notes.Setup(n => n.GetContactNotesByContactIdAsync("c1")).ReturnsAsync(new List + { + new ContactNote { ContactNoteId = "live", ContactId = "c1" }, new ContactNote { ContactNoteId = "gone", ContactId = "c1", IsDeleted = true } + }); + var types = new Mock(); + types.Setup(t => t.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + var service = Contacts(notes, types); + + (await service.GetContactNotesByContactIdAsync("c1", Dept)).Select(n => n.ContactNoteId).Should().Equal("live"); + (await service.GetContactNotesByContactIdAsync("c1", Dept, getDeleted: true)).Select(n => n.ContactNoteId).Should().Equal("live", "gone"); + } + + [Test] + public async Task Setting_a_members_roles_refreshes_that_member_once() + { + var roles = new Mock(); + roles.Setup(r => r.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 3, DepartmentId = Dept, Name = "Engineer" } }); + roles.Setup(r => r.GetPersonnelRolesByDepartmentIdAsync(Dept)).ReturnsAsync(new List { new PersonnelRole { PersonnelRoleId = 3, DepartmentId = Dept, Name = "Engineer" } }); + var roleUsers = new Mock(); + roleUsers.Setup(r => r.GetAllRoleUsersForUserAsync(Dept, "u1")).ReturnsAsync(new List()); + var service = new PersonnelRolesService(roles.Object, roleUsers.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), null, new Lazy(() => _search.Object)); + + await service.SetRolesForUserAsync(Dept, "u1", new[] { "3" }); + + _refreshed.Should().Equal((Dept, SearchEntityTypes.Personnel, "u1")); + } + + [Test] + public async Task Deleting_a_role_refreshes_its_former_members() + { + var roles = new Mock(); + roles.Setup(r => r.GetRoleByRoleIdAsync(3)).ReturnsAsync(new PersonnelRole { PersonnelRoleId = 3, DepartmentId = Dept, Name = "Engineer" }); + roles.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + var roleUsers = new Mock(); + roleUsers.Setup(r => r.GetAllMembersOfRoleAsync(3)).ReturnsAsync(new List + { + new PersonnelRoleUser { PersonnelRoleId = 3, DepartmentId = Dept, UserId = "u1" }, new PersonnelRoleUser { PersonnelRoleId = 3, DepartmentId = Dept, UserId = "u2" } + }); + var unitOfWork = new Mock(); + unitOfWork.Setup(u => u.CreateOrGetConnection()).Returns((DbConnection)null); + var service = new PersonnelRolesService(roles.Object, roleUsers.Object, Mock.Of(), Mock.Of(), + Mock.Of(), unitOfWork.Object, null, new Lazy(() => _search.Object)); + + (await service.DeleteRoleByIdAsync(3)).Should().BeTrue(); + + _refreshed.Select(r => r.Id).Should().BeEquivalentTo(new[] { "u1", "u2" }); + _refreshed.Should().OnlyContain(r => r.Department == Dept && r.Type == SearchEntityTypes.Personnel); + } + } +} diff --git a/Tests/Resgrid.Tests/Search/SearchSnippetsTests.cs b/Tests/Resgrid.Tests/Search/SearchSnippetsTests.cs new file mode 100644 index 000000000..92e443cec --- /dev/null +++ b/Tests/Resgrid.Tests/Search/SearchSnippetsTests.cs @@ -0,0 +1,63 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model.Search; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Search +{ + /// The excerpt and highlight helpers behind the search page: phrases first, literal matching, encoded output. + [TestFixture] + public class SearchSnippetsTests + { + [Test] + public void Terms_list_phrases_before_their_words_and_drop_single_characters() + { + var terms = SearchSnippets.Terms("\"trouble alarm\" bldg x, room"); + + terms.First().Should().Be("trouble alarm"); + terms.Should().Contain(new[] { "trouble", "alarm", "bldg", "room" }); + terms.Should().NotContain("x"); + } + + [Test] + public void The_excerpt_centres_on_the_phrase_and_marks_cuts_with_an_ellipsis() + { + var text = string.Join(" ", Enumerable.Repeat("Routine alarm check logged.", 20)) + " Alarm company: trouble alarm @ Bldg 4, room 12. " + + string.Join(" ", Enumerable.Repeat("Crew returned to quarters.", 20)); + + var snippet = SearchSnippets.Build(text, SearchSnippets.Terms("\"trouble alarm\""), 120); + + snippet.Should().Contain("trouble alarm @ Bldg 4"); + snippet.Should().StartWith("…").And.EndWith("…"); + snippet.Length.Should().BeLessThanOrEqualTo(122); + } + + [Test] + public void A_short_text_comes_back_whole_and_a_miss_comes_back_null() + { + SearchSnippets.Build("Trouble alarm, Bldg 4", new[] { "bldg" }).Should().Be("Trouble alarm, Bldg 4"); + SearchSnippets.Build("Medical call", new[] { "alarm" }).Should().BeNull(); + SearchSnippets.Build(null, new[] { "alarm" }).Should().BeNull(); + } + + [Test] + public void Highlighting_encodes_the_text_and_marks_only_the_terms() + { + var html = SearchHighlighter.Highlight(" Trouble ALARM & more", new[] { "trouble alarm", "alarm" }).ToString(); + + html.Should().Contain("<script>"); + html.Should().Contain("Trouble ALARM", "the phrase is marked whole, case-insensitively"); + html.Should().Contain("& more"); + html.Should().NotContain("" }; + var station = new DepartmentGroup { DepartmentGroupId = 1, Name = "Station 1", Children = new List { child } }; + var engines = new DepartmentGroup { DepartmentGroupId = 3, Name = "Engine & Ladder" }; + + var nodes = BSTreeModel.ForDepartmentGroups(new[] { station, child, engines }); + + nodes.Select(n => n.id).Should().Equal("TreeGroup_1", "TreeGroup_3"); + nodes[0].text.Should().Be("<b>Station 1</b>"); + nodes[1].text.Should().Be("Engine & Ladder"); + nodes[0].nodes.Should().ContainSingle().Which.Should().BeEquivalentTo(new { id = "TreeGroup_2", text = "<script>alert(1)</script>", icon = "" }); + nodes.Should().OnlyContain(n => n.icon == ""); + nodes[1].nodes.Should().BeEmpty(); + } + + [Test] + public void ForDepartmentGroups_WithNoGroups_ReturnsNoNodes() + { + BSTreeModel.ForDepartmentGroups(null).Should().BeEmpty(); + BSTreeModel.ForDepartmentGroups(new List()).Should().BeEmpty(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs index fa4175b93..931072b9a 100644 --- a/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs +++ b/Tests/Resgrid.Tests/Web/User/ProfileReportScheduleSecurityTests.cs @@ -63,7 +63,7 @@ public void SetUp() externalIdentityLinkService: null, userSessionService: null, systemAuditsService: null, departmentGroupsService: null, departmentSettingsService: null, passwordRecoveryService: null, eventAggregator: null, protectedReadService: null, businessOperationsAccess: Mock.Of(), - limitsService: null, profileLocalizer: null) + limitsService: null, profileLocalizer: null, mfaPolicyService: null, mfaEvidenceService: null, ssoBroker: null, ssoReturnTargets: null) { ControllerContext = new ControllerContext { HttpContext = http } }; diff --git a/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs index caf5a7651..0f4019ca2 100644 --- a/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/User/SecurityControllerTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; using Moq; using NUnit.Framework; using Resgrid.Model; @@ -27,6 +28,7 @@ public class SecurityControllerTests private Mock _departmentsService; private Mock _auditService; private Mock _permissionsService; + private Mock> _secLocalizer; private SecurityController _controller; [SetUp] @@ -35,6 +37,10 @@ public void SetUp() _departmentsService = new Mock(); _auditService = new Mock(); _permissionsService = new Mock(); + // Like ASP.NET's localizer, an unknown key comes back as its own name with ResourceNotFound set. + _secLocalizer = new Mock>(); + _secLocalizer.Setup(l => l[It.IsAny()]) + .Returns((string name) => new LocalizedString(name, name, resourceNotFound: true)); var httpContext = new DefaultHttpContext { @@ -58,10 +64,13 @@ public void SetUp() Mock.Of(), Mock.Of(), null, - null, + _secLocalizer.Object, Mock.Of(), Mock.Of(), - Mock.Of()) + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; @@ -179,6 +188,37 @@ public async Task GetRolesForPermission_ExplicitEmptyInventoryRule_DoesNotInheri _permissionsService.Verify(s => s.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.AdjustInventory), Times.Never); } + [Test] + public async Task GetAuditLogsList_UsesLocalizedTypeNamesAndFallsBackToTheAuditServiceName() + { + var localizedType = new AuditLog { AuditLogId = 1, DepartmentId = DepartmentId, UserId = "actor-1", LoggedOn = DateTime.UtcNow, LogType = (int)AuditLogTypes.UserAdded }; + var untranslatedType = new AuditLog { AuditLogId = 2, DepartmentId = DepartmentId, LogType = (int)AuditLogTypes.UserRemoved }; + _auditService.Setup(x => x.GetAllAuditLogsForDepartmentAsync(DepartmentId)) + .ReturnsAsync(new List { localizedType, untranslatedType }); + _auditService.Setup(x => x.GetAuditLogTypeString(AuditLogTypes.UserRemoved)).Returns("User Removed"); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, TimeZone = "UTC" }); + _departmentsService.Setup(x => x.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); + _departmentsService.Setup(x => x.GetAllUsersForDepartmentAsync(DepartmentId, true, false)).ReturnsAsync(new List()); + _secLocalizer.Setup(l => l["AuditLogTypeUserAdded"]).Returns(new LocalizedString("AuditLogTypeUserAdded", "Benutzer hinzugefügt")); + _secLocalizer.Setup(l => l["AuditLogsSystemActor"]).Returns(new LocalizedString("AuditLogsSystemActor", "System (de)")); + _secLocalizer.Setup(l => l["AuditLogsUnknownTime"]).Returns(new LocalizedString("AuditLogsUnknownTime", "Unbekannt")); + + var result = await _controller.GetAuditLogsList(); + + var entries = result.Should().BeOfType().Subject.Value + .Should().BeAssignableTo>().Subject.ToList(); + var translated = entries.Single(x => x.AuditLogId == 1); + translated.Type.Should().Be("Benutzer hinzugefügt"); + translated.SearchTerms.Should().Contain("Benutzer hinzugefügt").And.Contain(AuditLogTypes.UserAdded.ToString()); + _auditService.Verify(x => x.GetAuditLogTypeString(AuditLogTypes.UserAdded), Times.Never); + + var fallback = entries.Single(x => x.AuditLogId == 2); + fallback.Type.Should().Be("User Removed", "a type without a Security resource falls back to the audit service's English name"); + fallback.Name.Should().Be("System (de)"); + fallback.Timestamp.Should().Be("Unbekannt"); + } + [Test] public async Task ViewAudit_ReturnsCompleteAuditEntryAndFriendlyTypeName() { @@ -213,6 +253,23 @@ public async Task ViewAudit_ReturnsCompleteAuditEntryAndFriendlyTypeName() model.TypeName.Should().Be("User Added"); } + [Test] + public async Task ViewAudit_UsesLocalizedTypeName() + { + _auditService.Setup(x => x.GetAuditLogByIdAsync(43)) + .ReturnsAsync(new AuditLog { AuditLogId = 43, DepartmentId = DepartmentId, LogType = (int)AuditLogTypes.UserAdded }); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)) + .ReturnsAsync(new Department { DepartmentId = DepartmentId }); + _secLocalizer.Setup(l => l["AuditLogTypeUserAdded"]).Returns(new LocalizedString("AuditLogTypeUserAdded", "Utente aggiunto")); + + var result = await _controller.ViewAudit(43); + + var model = result.Should().BeOfType().Subject.Model.Should().BeOfType().Subject; + model.Type.Should().Be(AuditLogTypes.UserAdded, "the raw type stays available as an identifier"); + model.TypeName.Should().Be("Utente aggiunto"); + _auditService.Verify(x => x.GetAuditLogTypeString(It.IsAny()), Times.Never); + } + [Test] public async Task ViewAudit_MissingEntry_ReturnsNotFound() { diff --git a/Tests/Resgrid.Tests/Web/User/UnitTrackingDisplayHelperTests.cs b/Tests/Resgrid.Tests/Web/User/UnitTrackingDisplayHelperTests.cs new file mode 100644 index 000000000..ec68502da --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/UnitTrackingDisplayHelperTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Resources; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Web.Helpers; +using CommonResource = Resgrid.Localization.Common; +using UnitsResource = Resgrid.Localization.Areas.User.Units.Units; + +namespace Resgrid.Tests.Web.User +{ + /// + /// The UnitTracking pages used to print the tracking enums raw ("CustomHeader", "NeverSeen", + /// "FixtureVerified") in every language. Every value needs a key in Units.en.resx; + /// ResourceKeyParityTests then requires it in each locale. + /// + [TestFixture] + public class UnitTrackingDisplayHelperTests + { + private const string UnitsPrefix = "Units:"; + private const string CommonUnknown = "Common:Unknown"; + + private IStringLocalizer _units; + private IStringLocalizer _common; + private HashSet _unitsKeys; + + [SetUp] + public void SetUp() + { + _units = EchoLocalizer("Units"); + _common = EchoLocalizer("Common"); + _unitsKeys = EnglishKeys(typeof(UnitsResource)); + } + + [Test] + public void every_auth_mode_should_have_a_label() + { + AssertEveryValueHasALabel(mode => UnitTrackingDisplayHelper.GetLocalizedAuthMode(mode, _units, _common)); + } + + [Test] + public void every_transport_should_have_a_label() + { + AssertEveryValueHasALabel(transport => UnitTrackingDisplayHelper.GetLocalizedTransport(transport, _units, _common)); + } + + [Test] + public void every_device_status_should_have_a_label() + { + AssertEveryValueHasALabel(status => UnitTrackingDisplayHelper.GetLocalizedDeviceStatus(status, _units)); + } + + [Test] + public void every_certification_status_should_have_a_label() + { + AssertEveryValueHasALabel(status => UnitTrackingDisplayHelper.GetLocalizedCertificationStatus(status, _units, _common)); + } + + [Test] + public void unknown_values_should_use_the_common_unknown_label() + { + EnglishKeys(typeof(CommonResource)).Should().Contain("Unknown"); + + UnitTrackingDisplayHelper.GetLocalizedAuthMode(UnitTrackingAuthMode.Unknown, _units, _common).Should().Be(CommonUnknown); + UnitTrackingDisplayHelper.GetLocalizedTransport(UnitTrackingTransportType.Unknown, _units, _common).Should().Be(CommonUnknown); + UnitTrackingDisplayHelper.GetLocalizedCertificationStatus(UnitTrackingCertificationStatus.Unknown, _units, _common).Should().Be(CommonUnknown); + } + + [Test] + public void unmapped_values_should_fall_back_to_the_enum_name() + { + UnitTrackingDisplayHelper.GetLocalizedAuthMode((UnitTrackingAuthMode)99, _units, _common).Should().Be("99"); + UnitTrackingDisplayHelper.GetLocalizedDeviceStatus((UnitTrackingDeviceStatus)99, _units).Should().Be("99"); + } + + [Test] + public void labels_should_follow_the_request_culture() + { + var previous = CultureInfo.CurrentUICulture; + try + { + using var provider = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider(); + var units = provider.GetRequiredService>(); + var common = provider.GetRequiredService>(); + + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de"); + UnitTrackingDisplayHelper.GetLocalizedAuthMode(UnitTrackingAuthMode.Basic, units, common).Should().Be("Basic-Authentifizierung"); + UnitTrackingDisplayHelper.GetLocalizedTransport(UnitTrackingTransportType.Unknown, units, common).Should().Be("Unbekannt"); + + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + UnitTrackingDisplayHelper.GetLocalizedAuthMode(UnitTrackingAuthMode.CapabilityPath, units, common).Should().Be("Capability URL (token in path)"); + } + finally + { + CultureInfo.CurrentUICulture = previous; + } + } + + private void AssertEveryValueHasALabel(Func render) where TEnum : struct, Enum + { + var keys = new List(); + foreach (var value in Enum.GetValues()) + { + var label = render(value); + if (label == CommonUnknown) + { + value.ToString().Should().Be("Unknown", "only the Unknown value should borrow the Common label"); + continue; + } + + label.Should().StartWith(UnitsPrefix, $"{typeof(TEnum).Name}.{value} should render a Units resource, not its enum name"); + var key = label.Substring(UnitsPrefix.Length); + _unitsKeys.Should().Contain(key, $"{typeof(TEnum).Name}.{value} uses {key}, which Units.en.resx must define"); + keys.Add(key); + } + + keys.Should().OnlyHaveUniqueItems($"each {typeof(TEnum).Name} value needs its own label"); + } + + private static IStringLocalizer EchoLocalizer(string resource) + { + var localizer = new Mock>(); + localizer.Setup(l => l[It.IsAny()]).Returns((string name) => new LocalizedString(name, $"{resource}:{name}")); + return localizer.Object; + } + + private static HashSet EnglishKeys(Type resource) + { + var manager = new ResourceManager(resource.FullName!, resource.Assembly); + return manager.GetResourceSet(CultureInfo.GetCultureInfo("en"), true, false)! + .Cast() + .Select(entry => (string)entry.Key) + .ToHashSet(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/resgrid-account-passkeys.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-account-passkeys.test.cjs new file mode 100644 index 000000000..ea7dcab33 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-account-passkeys.test.cjs @@ -0,0 +1,142 @@ +// Passkeys on the Web account security page (passkey workbook section 12, slice 19): add, rename and remove through the +// server, following the server when it says to confirm the password or verify again first. Runs the real page script in +// headless Chrome with jQuery real and the server, the browser ceremony, confirm and prompt stubbed. +// node Tests/Resgrid.Tests/Web/resgrid-account-passkeys.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const root = path.resolve(__dirname, '../../..'); +const jquery = path.join(root, 'Web/Resgrid.Web/wwwroot/lib/jquery/dist/jquery.min.js'); +const script = path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.account.passkeys.js'); + +const page_ = ` +
+ + + +
Laptop
+`; + +async function harness(page, supported = true) { + await page.setContent(page_); + await page.addScriptTag({ path: jquery }); + await page.evaluate((isSupported) => { + window.posts = []; window.went = []; window.ceremonies = []; window.promptAnswer = null; window.confirmAnswer = true; + $.post = function (url, data) { var d = $.Deferred(); window.posts.push({ url: url, data: data, d: d }); return d.promise(); }; + window.prompt = function () { return window.promptAnswer; }; + window.confirm = function () { return window.confirmAnswer; }; + window.resgridPasskeys = { + isSupported: () => isSupported, + register: (options) => new Promise((resolve, reject) => window.ceremonies.push({ options, resolve, reject })) + }; + }, supported); + await page.addScriptTag({ path: script }); + await page.evaluate(() => resgridAccountPasskeys.init({ + optionsUrl: '/options', completeUrl: '/complete', renameUrl: '/rename', removeUrl: '/remove', pageUrl: '/User/TwoFactor', signInUrl: '/Account/LogOn', + navigate: (url) => window.went.push(url), + messages: { failed: 'failed', cancelled: 'cancelled', notSupported: 'not supported', alreadyRegistered: 'already', limit: 'limit', + unavailable: 'unavailable', renamePrompt: 'New name', removeConfirm: 'Remove?' } + })); +} + +const resolvePost = (page, index, response) => page.evaluate(([i, r]) => posts[i].d.resolve(r), [index, response]); +const errorText = (page) => page.evaluate(() => $('#passkeyError').is(':visible') ? $('#passkeyError').text() : null); + +(async () => { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + // ---- Adding: options from the server, the browser ceremony, the result back, then the page reloads ---- + let page = await browser.newPage(); + await harness(page); + await page.click('#passkeyAdd'); + assert.deepEqual(await page.evaluate(() => [posts[0].url, posts[0].data.__RequestVerificationToken]), ['/options', 'af']); + assert.equal(await page.isDisabled('#passkeyAdd'), true, 'one ceremony at a time'); + await resolvePost(page, 0, { success: true, requestId: 'r1', options: '{"challenge":"abc"}' }); + assert.equal(await page.evaluate(() => ceremonies[0].options), '{"challenge":"abc"}'); + await page.evaluate(() => ceremonies[0].resolve({ id: 'new', type: 'public-key' })); + assert.deepEqual(await page.evaluate(() => [posts[1].url, posts[1].data.requestId, posts[1].data.credential, posts[1].data.displayName]), + ['/complete', 'r1', '{"id":"new","type":"public-key"}', 'Desk key']); + await resolvePost(page, 1, { success: true }); + assert.deepEqual(await page.evaluate(() => went), ['/User/TwoFactor?passkeyStatus=added#passkeys']); + await page.close(); + + // ---- The server says who you are must be confirmed first: the page goes where it says ---- + page = await browser.newPage(); + await harness(page); + await page.click('#passkeyAdd'); + await resolvePost(page, 0, { success: false, error: 'reauthentication_required', redirect: '/User/AccountSecurity/Reauthenticate?returnUrl=%2FUser%2FTwoFactor' }); + assert.deepEqual(await page.evaluate(() => went), ['/User/AccountSecurity/Reauthenticate?returnUrl=%2FUser%2FTwoFactor']); + assert.equal(await page.evaluate(() => ceremonies.length), 0, 'no prompt before the server agrees'); + await page.close(); + + // ---- A closed prompt, a duplicate, the limit, and an unsupported browser are each told plainly ---- + page = await browser.newPage(); + await harness(page); + await page.click('#passkeyAdd'); + await resolvePost(page, 0, { success: true, requestId: 'r1', options: '{}' }); + await page.evaluate(() => ceremonies[0].reject({ outcome: 'cancelled' })); + assert.equal(await errorText(page), 'cancelled'); + assert.equal(await page.evaluate(() => posts.length), 1, 'nothing is sent for a cancelled prompt'); + assert.equal(await page.isDisabled('#passkeyAdd'), false); + await page.click('#passkeyAdd'); + await resolvePost(page, 1, { success: true, requestId: 'r2', options: '{}' }); + await page.evaluate(() => ceremonies[1].reject({ outcome: 'already_registered' })); + assert.equal(await errorText(page), 'already'); + await page.click('#passkeyAdd'); + await resolvePost(page, 2, { success: false, error: 'passkey_limit_reached' }); + assert.equal(await errorText(page), 'limit'); + await page.close(); + + page = await browser.newPage(); + await harness(page, false); + await page.click('#passkeyAdd'); + assert.equal(await errorText(page), 'not supported'); + assert.equal(await page.evaluate(() => posts.length), 0); + await page.close(); + + // ---- Rename any passkey; an unchanged name sends nothing ---- + page = await browser.newPage(); + await harness(page); + await page.evaluate(() => { window.promptAnswer = 'Laptop'; }); + await page.click('.passkey-rename'); + assert.equal(await page.evaluate(() => posts.length), 0, 'an unchanged name sends nothing'); + await page.evaluate(() => { window.promptAnswer = ' Work laptop '; }); + await page.click('.passkey-rename'); + assert.deepEqual(await page.evaluate(() => [posts[0].url, posts[0].data.id, posts[0].data.displayName]), ['/rename', 'pk-web', 'Work laptop']); + await resolvePost(page, 0, { success: true }); + assert.deepEqual(await page.evaluate(() => went), ['/User/TwoFactor?passkeyStatus=renamed#passkeys']); + await page.close(); + + // ---- Remove: confirmed first, verify again when the server asks, and sign out when it was this session's passkey ---- + page = await browser.newPage(); + await harness(page); + await page.evaluate(() => { window.confirmAnswer = false; }); + await page.click('.passkey-remove'); + assert.equal(await page.evaluate(() => posts.length), 0, 'nothing is removed without confirmation'); + await page.evaluate(() => { window.confirmAnswer = true; }); + await page.click('.passkey-remove'); + assert.deepEqual(await page.evaluate(() => [posts[0].url, posts[0].data.id]), ['/remove', 'pk-web']); + await resolvePost(page, 0, { success: false, error: 'step_up_required', redirect: '/User/TwoFactor/Verify2FA?returnUrl=%2FUser%2FTwoFactor' }); + assert.deepEqual(await page.evaluate(() => went), ['/User/TwoFactor/Verify2FA?returnUrl=%2FUser%2FTwoFactor']); + await page.close(); + + page = await browser.newPage(); + await harness(page); + await page.click('.passkey-remove'); + await resolvePost(page, 0, { success: true, signedOut: true }); + assert.deepEqual(await page.evaluate(() => went), ['/Account/LogOn'], 'removing the passkey this session signed in with ends the session'); + await page.close(); + + page = await browser.newPage(); + await harness(page); + await page.click('.passkey-remove'); + await resolvePost(page, 0, { success: true, signedOut: false }); + assert.deepEqual(await page.evaluate(() => went), ['/User/TwoFactor?passkeyStatus=removed#passkeys']); + await page.close(); + + console.log('resgrid-account-passkeys.test.cjs passed'); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs index 283470e72..449aab2d0 100644 --- a/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs +++ b/Tests/Resgrid.Tests/Web/resgrid-adp-reveal.test.cjs @@ -13,7 +13,9 @@ const MINUTE = 60 * 1000; const chrome = `
-
`; +
+ +
`; async function harness(page, html, options) { await page.clock.install({ time: new Date('2026-09-05T12:00:00Z') }); @@ -148,6 +150,147 @@ const expiresIn = (page, minutes) => page.evaluate((m) => new Date(Date.now() + assert.equal(await warningText(page), null, 'a page without a form has nothing to hold; the banner takes over'); await page.close(); + // ---- Other ways to verify (slice 19): offered only when the server lists them; a closed passkey prompt is no failure ---- + const choices = { methodsUrl: '/methods', passkeyOptionsUrl: '/passkey-options', verifyPasskeyUrl: '/verify-passkey', + requestApprovalUrl: '/request-approval', approvalStatusUrl: '/approval-status', completeApprovalUrl: '/complete-approval' }; + const openPrompt = async (methods) => { + await page.click('#adpRevealButton'); + await resolvePost(page, 0, { success: false, error: 'step_up_required' }); + assert.equal(await page.isVisible('#adpUsePasskey'), false, 'nothing but the code before the server answers'); + assert.deepEqual(await page.evaluate(() => [ajaxCalls[0].options.url, ajaxCalls[0].options.method]), ['/methods', 'GET']); + await page.evaluate((m) => ajaxCalls[0].d.resolve({ success: true, methods: m }), methods); + }; + page = await browser.newPage(); + await harness(page, ``, choices); + await page.evaluate(() => { + window.passkeyCalls = []; + window.resgridPasskeys = { isSupported: () => true, authenticate: (o) => new Promise((resolve, reject) => window.passkeyCalls.push({ options: o, resolve, reject })) }; + }); + await openPrompt(['totp', 'passkey', 'passkey_approval']); + assert.equal(await page.isVisible('#adpUsePasskey'), true); + assert.equal(await page.isVisible('#adpUseResponder'), true); + + await page.click('#adpUsePasskey'); + assert.equal(await page.evaluate(() => posts[1].url), '/passkey-options'); + await resolvePost(page, 1, { success: true, requestId: 'q1', options: '{"challenge":"abc"}' }); + assert.equal(await page.evaluate(() => passkeyCalls[0].options), '{"challenge":"abc"}', 'the server options go to the browser unchanged'); + await page.evaluate(() => passkeyCalls[0].reject({ outcome: 'cancelled' })); + assert.match(await page.textContent('#adpStepUpError'), /closed/, 'a closed prompt is told as such'); + assert.equal(await page.evaluate(() => posts.length), 2, 'nothing is sent for a cancelled prompt'); + + await page.click('#adpUsePasskey'); + await resolvePost(page, 2, { success: true, requestId: 'q2', options: '{"challenge":"def"}' }); + await page.evaluate(() => passkeyCalls[1].resolve({ id: 'cred-1', type: 'public-key' })); + assert.deepEqual(await page.evaluate(() => [posts[3].url, posts[3].data.requestId, posts[3].data.credential]), + ['/verify-passkey', 'q2', '{"id":"cred-1","type":"public-key"}']); + await resolvePost(page, 3, { success: true, grantToken: 'P1', expiresOnUtc: await expiresIn(page, 15) }); + assert.equal(await page.evaluate(() => modalCalls.slice(-1)[0]), 'hide'); + assert.equal(await page.evaluate(() => ajaxCalls[1].options.headers['X-Resgrid-Protected-Grant']), 'P1', 'the passkey grant reveals'); + await page.close(); + + // ---- Approve with Responder: the number shows here, the decision is polled, then used once ---- + page = await browser.newPage(); + await harness(page, ``, choices); + await page.evaluate(() => { window.resgridPasskeys = { isSupported: () => true, authenticate: () => new Promise(() => {}) }; }); + await openPrompt(['totp', 'passkey_approval']); + assert.equal(await page.isVisible('#adpUsePasskey'), false, 'no passkey offered when the server lists none, even where the browser can'); + await page.click('#adpUseResponder'); + assert.equal(await page.evaluate(() => posts[1].url), '/request-approval'); + await resolvePost(page, 1, { success: true, approvalRequestId: 'a1', matchNumber: '42', expiresIn: 120 }); + assert.equal(await page.textContent('#adpApprovalNumber'), '42'); + assert.match(await page.textContent('#adpApprovalStatus'), /Waiting/); + await page.clock.fastForward(2000); + assert.deepEqual(await page.evaluate(() => [posts[2].url, posts[2].data.approvalRequestId]), ['/approval-status', 'a1']); + await resolvePost(page, 2, { success: true, state: 'pending' }); + await page.clock.fastForward(2000); + await resolvePost(page, 3, { success: true, state: 'approved' }); + assert.deepEqual(await page.evaluate(() => [posts[4].url, posts[4].data.approvalRequestId]), ['/complete-approval', 'a1']); + await resolvePost(page, 4, { success: true, grantToken: 'A1', expiresOnUtc: await expiresIn(page, 15) }); + assert.equal(await page.evaluate(() => ajaxCalls[1].options.headers['X-Resgrid-Protected-Grant']), 'A1'); + await page.clock.fastForward(10000); + assert.equal(await page.evaluate(() => posts.length), 5, 'polling stops once the approval is used'); + await page.close(); + + page = await browser.newPage(); + await harness(page, ``, choices); + await openPrompt(['totp', 'passkey_approval']); + await page.click('#adpUseResponder'); + await resolvePost(page, 1, { success: true, approvalRequestId: 'a2', matchNumber: '17', expiresIn: 120 }); + await page.clock.fastForward(2000); + await resolvePost(page, 2, { success: true, state: 'denied' }); + assert.match(await page.textContent('#adpStepUpError'), /denied/); + assert.equal(await page.isVisible('#adpApprovalPanel'), false); + await page.clock.fastForward(10000); + assert.equal(await page.evaluate(() => posts.length), 3, 'a denial ends the wait'); + + await page.click('#adpUseResponder'); + await resolvePost(page, 3, { success: true, approvalRequestId: 'a3', matchNumber: '55', expiresIn: 120 }); + await page.evaluate(() => $('#adpStepUpModal').modal('hide')); + await page.clock.fastForward(10000); + assert.equal(await page.evaluate(() => posts.length), 4, 'closing the prompt stops waiting for the approval'); + await page.close(); + + // ---- Provider step-up (slice 22): a popup through the identity provider hands the grant back, and only that popup's answer counts ---- + page = await browser.newPage(); + await harness(page, ``, choices); + await openPrompt(['totp', 'federated']); + assert.equal(await page.isVisible('#adpUseProvider'), false, 'no provider button where the Web cannot return from the provider'); + await page.close(); + + page = await browser.newPage(); + await harness(page, ``, Object.assign({ federatedUrl: '/Account/SsoSessionBegin' }, choices)); + await page.evaluate(() => { + window.opened = []; window.formsSent = []; + // A real window object stands in for the popup, so the page can recognise its messages. + window.open = function (url, name) { + var frame = document.createElement('iframe'); + document.body.appendChild(frame); + window.opened.push({ url: url, name: name, win: frame.contentWindow }); + return frame.contentWindow; + }; + HTMLFormElement.prototype.submit = function () { + window.formsSent.push({ action: this.getAttribute('action'), target: this.target, method: this.method, + purpose: this.querySelector('[name=purpose]').value, token: this.querySelector('[name=__RequestVerificationToken]').value }); + }; + }); + await openPrompt(['totp', 'federated']); + assert.equal(await page.isVisible('#adpUseProvider'), true); + await page.click('#adpUseProvider'); + assert.deepEqual(await page.evaluate(() => [opened[0].name, formsSent[0].action, formsSent[0].target, formsSent[0].method, formsSent[0].purpose, formsSent[0].token]), + ['resgridAdpProvider', '/Account/SsoSessionBegin', 'resgridAdpProvider', 'post', 'adp', 'af'], 'the popup posts the begin with the antiforgery token'); + assert.equal(await page.evaluate(() => document.querySelectorAll('form[target=resgridAdpProvider]').length), 0, 'the begin form is not left behind'); + + const grantMessage = (source, token) => page.evaluate(([s, t]) => { + var from = s === 'popup' ? opened[0].win : window; + window.dispatchEvent(new MessageEvent('message', { origin: window.location.origin, source: from, + data: { type: 'resgrid-adp-grant', success: true, grantToken: t, expiresOnUtc: new Date(Date.now() + 15 * 60000).toISOString() } })); + }, [source, token]); + await grantMessage('self', 'FORGED'); + assert.equal(await page.evaluate(() => ajaxCalls.length), 1, 'an answer from any other window is ignored'); + await grantMessage('popup', 'F1'); + assert.equal(await page.evaluate(() => modalCalls.slice(-1)[0]), 'hide'); + assert.equal(await page.evaluate(() => ajaxCalls[1].options.headers['X-Resgrid-Protected-Grant']), 'F1', 'the provider grant reveals'); + await grantMessage('popup', 'F2'); + assert.equal(await page.evaluate(() => ajaxCalls.length), 2, 'the popup answers once'); + await page.close(); + + page = await browser.newPage(); + await harness(page, ``, Object.assign({ federatedUrl: '/Account/SsoSessionBegin' }, choices)); + await page.evaluate(() => { window.open = function () { return null; }; }); + await openPrompt(['totp', 'federated']); + await page.click('#adpUseProvider'); + assert.match(await page.textContent('#adpStepUpError'), /pop-ups/, 'a blocked popup is told as such'); + await page.evaluate(() => { + window.open = function (url, name) { var f = document.createElement('iframe'); document.body.appendChild(f); window.popup = f.contentWindow; return f.contentWindow; }; + HTMLFormElement.prototype.submit = function () {}; + }); + await page.click('#adpUseProvider'); + await page.evaluate(() => window.dispatchEvent(new MessageEvent('message', { origin: window.location.origin, source: window.popup, + data: { type: 'resgrid-adp-grant', success: false, error: 'mfa_verification_failed' } }))); + assert.notEqual(await page.textContent('#adpStepUpError'), '', 'a failed provider step-up says so'); + assert.equal(await page.evaluate(() => ajaxCalls.length), 1, 'and reveals nothing'); + await page.close(); + console.log('resgrid-adp-reveal.test.cjs passed'); } finally { await browser.close(); diff --git a/Tests/Resgrid.Tests/Web/resgrid-mfa-choice.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-mfa-choice.test.cjs new file mode 100644 index 000000000..f10ccd346 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-mfa-choice.test.cjs @@ -0,0 +1,180 @@ +// Second-factor choices on Web sign-in and step-up (passkey workbook section 12, slice 21): a passkey for the web and approval +// from Responder, with the server deciding every outcome and naming where to go. Runs the real page script in headless Chrome +// with jQuery real and the server and browser ceremony stubbed. +// node Tests/Resgrid.Tests/Web/resgrid-mfa-choice.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const root = path.resolve(__dirname, '../../..'); +const jquery = path.join(root, 'Web/Resgrid.Web/wwwroot/lib/jquery/dist/jquery.min.js'); +const script = path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.mfa.choice.js'); + +const markup = ` +
+ + +`; + +async function harness(page, { supported = true, approval = true } = {}) { + await page.setContent(markup); + await page.addScriptTag({ path: jquery }); + await page.evaluate(({ isSupported }) => { + window.posts = []; window.went = []; window.ceremonies = []; window.intervals = []; + $.post = function (url, data) { var d = $.Deferred(); window.posts.push({ url: url, data: data, d: d }); return d.promise(); }; + // Polling is driven by the test, not the clock. + window.setInterval = function (fn) { window.intervals.push(fn); return window.intervals.length; }; + window.clearInterval = function (id) { window.intervals[id - 1] = null; }; + window.resgridPasskeys = { + isSupported: () => isSupported, + authenticate: (options) => new Promise((resolve, reject) => window.ceremonies.push({ options, resolve, reject })) + }; + }, { isSupported: supported }); + await page.addScriptTag({ path: script }); + await page.evaluate((withApproval) => resgridMfaChoice.init({ + antiForgeryToken: () => $('#f input[name="__RequestVerificationToken"]').val(), + extra: () => ({ returnUrl: $('#ReturnUrl').val() }), + passkeyOptionsUrl: '/options', verifyPasskeyUrl: '/verify', + requestApprovalUrl: withApproval ? '/request' : undefined, approvalStatusUrl: '/status', completeApprovalUrl: '/complete', + navigate: (url) => window.went.push(url), + messages: { + failed: 'failed', passkey_failed: 'passkey failed', passkey_cancelled: 'cancelled', passkey_not_supported: 'not supported', + approval_waiting: 'waiting', approval_denied: 'denied', approval_expired: 'expired', mfa_method_not_allowed: 'not allowed' + } + }), approval); +} + +const resolvePost = (page, index, response) => page.evaluate(([i, r]) => posts[i].d.resolve(r), [index, response]); +const errorText = (page) => page.evaluate(() => $('#mfaChoiceError').is(':visible') ? $('#mfaChoiceError').text() : null); +const poll = (page) => page.evaluate(() => { var live = intervals.filter(Boolean); live[live.length - 1](); }); + +(async () => { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + // ---- A passkey: options from the server, the prompt, the result back, then where the server says ---- + let page = await browser.newPage(); + await harness(page); + await page.click('#mfaUsePasskey'); + assert.deepEqual(await page.evaluate(() => [posts[0].url, posts[0].data.__RequestVerificationToken, posts[0].data.returnUrl]), ['/options', 'af', '/User/Calls']); + assert.equal(await page.isDisabled('#mfaUsePasskey'), true, 'one ceremony at a time'); + await page.click('#mfaUseResponder'); + assert.equal(await page.evaluate(() => posts.length), 1, 'nothing else starts while a ceremony runs'); + await resolvePost(page, 0, { success: true, requestId: 'r1', options: '{"challenge":"abc"}' }); + assert.equal(await page.evaluate(() => ceremonies[0].options), '{"challenge":"abc"}'); + await page.evaluate(() => ceremonies[0].resolve({ id: 'cred', type: 'public-key' })); + assert.deepEqual(await page.evaluate(() => [posts[1].url, posts[1].data.requestId, posts[1].data.credential, posts[1].data.returnUrl]), + ['/verify', 'r1', '{"id":"cred","type":"public-key"}', '/User/Calls']); + await resolvePost(page, 1, { success: true, redirect: '/User/Calls' }); + assert.deepEqual(await page.evaluate(() => went), ['/User/Calls']); + await page.close(); + + // ---- Only a local address the server named is followed ---- + page = await browser.newPage(); + await harness(page); + for (const target of ['https://evil.example/', '//evil.example/', '/\\evil.example']) { + await page.click('#mfaUsePasskey'); + const n = await page.evaluate(() => posts.length); + await resolvePost(page, n - 1, { success: true, requestId: 'r', options: '{}' }); + await page.evaluate(() => ceremonies[ceremonies.length - 1].resolve({ id: 'c' })); + await resolvePost(page, n, { success: true, redirect: target }); + assert.equal(await errorText(page), 'failed', target); + } + assert.deepEqual(await page.evaluate(() => went), []); + await page.close(); + + // ---- A closed prompt, a failed passkey, a refused method and an unsupported browser are each told plainly ---- + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUsePasskey'); + await resolvePost(page, 0, { success: true, requestId: 'r1', options: '{}' }); + await page.evaluate(() => ceremonies[0].reject({ outcome: 'cancelled' })); + assert.equal(await errorText(page), 'cancelled'); + assert.equal(await page.evaluate(() => posts.length), 1, 'nothing is sent for a closed prompt'); + assert.equal(await page.isDisabled('#mfaUsePasskey'), false); + await page.click('#mfaUsePasskey'); + await resolvePost(page, 1, { success: false, error: 'mfa_method_not_allowed' }); + assert.equal(await errorText(page), 'not allowed'); + await page.click('#mfaUsePasskey'); + await resolvePost(page, 2, { success: true, requestId: 'r3', options: '{}' }); + await page.evaluate(() => ceremonies[1].resolve({ id: 'c' })); + await resolvePost(page, 3, { success: false, error: 'passkey_verification_failed' }); + assert.equal(await errorText(page), 'failed'); + await page.close(); + + page = await browser.newPage(); + await harness(page, { supported: false }); + await page.click('#mfaUsePasskey'); + assert.equal(await errorText(page), 'not supported'); + assert.equal(await page.evaluate(() => posts.length), 0); + await page.close(); + + // ---- A sign-in that ended goes back to where the server says to start again ---- + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUsePasskey'); + await resolvePost(page, 0, { success: false, error: 'mfa_transaction_expired', restart: '/Account/LogOn?returnUrl=%2FUser%2FCalls' }); + assert.deepEqual(await page.evaluate(() => went), ['/Account/LogOn?returnUrl=%2FUser%2FCalls']); + await page.close(); + + // ---- Approval: the number on this page, polled until decided, then used once ---- + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUseResponder'); + assert.equal(await page.evaluate(() => posts[0].url), '/request'); + await resolvePost(page, 0, { success: true, approvalRequestId: 'ap-1', matchNumber: '47', expiresIn: 120 }); + assert.equal(await page.textContent('#mfaApprovalNumber'), '47'); + assert.equal(await page.textContent('#mfaApprovalStatus'), 'waiting'); + assert.equal(await page.isVisible('#mfaApprovalPanel'), true); + await poll(page); + assert.deepEqual(await page.evaluate(() => [posts[1].url, posts[1].data.approvalRequestId]), ['/status', 'ap-1']); + await resolvePost(page, 1, { success: true, state: 'pending' }); + assert.equal(await page.isVisible('#mfaApprovalPanel'), true, 'still waiting'); + await poll(page); + await resolvePost(page, 2, { success: true, state: 'approved' }); + assert.deepEqual(await page.evaluate(() => [posts[3].url, posts[3].data.approvalRequestId, posts[3].data.returnUrl]), ['/complete', 'ap-1', '/User/Calls']); + assert.equal(await page.isVisible('#mfaApprovalPanel'), false); + assert.equal(await page.evaluate(() => intervals.filter(Boolean).length), 0, 'polling stops once decided'); + await resolvePost(page, 3, { success: true, redirect: '/User/Dashboard' }); + assert.deepEqual(await page.evaluate(() => went), ['/User/Dashboard']); + await page.close(); + + // ---- A denied or expired request stops the wait with its reason; stopping the wait asks nothing more ---- + for (const [state, message] of [['denied', 'denied'], ['expired', 'expired'], ['canceled', 'expired']]) { + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUseResponder'); + await resolvePost(page, 0, { success: true, approvalRequestId: 'ap-1', matchNumber: '12' }); + await poll(page); + await resolvePost(page, 1, { success: true, state: state }); + assert.equal(await errorText(page), message, state); + assert.equal(await page.evaluate(() => posts.length), 2, 'nothing is completed'); + await page.close(); + } + + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUseResponder'); + await resolvePost(page, 0, { success: true, approvalRequestId: 'ap-1', matchNumber: '12' }); + await page.click('#mfaApprovalCancel'); + assert.equal(await page.isVisible('#mfaApprovalPanel'), false); + assert.equal(await page.evaluate(() => intervals.filter(Boolean).length), 0); + await page.click('#mfaUsePasskey'); + assert.equal(await page.evaluate(() => posts[posts.length - 1].url), '/options', 'another method can be chosen right away'); + await page.close(); + + // ---- Switching to a passkey while waiting ends the wait, and a late answer for the old request is ignored ---- + page = await browser.newPage(); + await harness(page); + await page.click('#mfaUseResponder'); + await resolvePost(page, 0, { success: true, approvalRequestId: 'ap-1', matchNumber: '12' }); + await poll(page); + await page.click('#mfaUsePasskey'); + await resolvePost(page, 1, { success: true, state: 'approved' }); + assert.equal(await page.evaluate(() => posts.filter(p => p.url === '/complete').length), 0, 'the old request is not used'); + await page.close(); + + console.log('resgrid-mfa-choice.test.cjs passed'); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/Tests/Resgrid.Tests/Web/resgrid-passkeys.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-passkeys.test.cjs new file mode 100644 index 000000000..8826fd609 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-passkeys.test.cjs @@ -0,0 +1,144 @@ +// Passkey ceremonies for Core Web (passkey workbook section 12, slice 19): resgridPasskeys turns the server's JSON options +// into what navigator.credentials needs and returns the credential as base64url JSON, the shape the server verifies. +// Runs the real module in headless Chrome against its built-in virtual authenticator (DevTools WebAuthn domain) on an +// HTTPS origin served by request interception, so the browser's own WebAuthn implementation does the ceremonies. +// node Tests/Resgrid.Tests/Web/resgrid-passkeys.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium } = require('./browser-launch.cjs').playwright(); +const root = path.resolve(__dirname, '../../..'); +const moduleSource = fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.passkeys.js'), 'utf8'); +const ORIGIN = 'https://web.resgrid.test'; +const B64URL = /^[A-Za-z0-9_-]+$/; + +const b64url = (buffer) => Buffer.from(buffer).toString('base64url'); +const decodeJson = (value) => JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); + +// The options as the server's Fido2 library writes them: base64url strings for every binary field. +function creationOptions(challenge, exclude = []) { + return JSON.stringify({ + rp: { id: 'web.resgrid.test', name: 'Resgrid' }, + user: { id: b64url(Buffer.from('user-handle-1')), name: 'user1', displayName: 'User One' }, + challenge: b64url(challenge), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], + timeout: 60000, + attestation: 'none', + authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, + excludeCredentials: exclude.map((id) => ({ type: 'public-key', id: id })), + extensions: { credProps: true } + }); +} + +function requestOptions(challenge, allow) { + return JSON.stringify({ + challenge: b64url(challenge), + timeout: 60000, + rpId: 'web.resgrid.test', + allowCredentials: allow.map((id) => ({ type: 'public-key', id: id })), + userVerification: 'required' + }); +} + +(async () => { + const browser = await chromium.launch(require('./browser-launch.cjs').launchOptions()); + try { + const context = await browser.newContext(); + await context.route(ORIGIN + '/**', (route) => route.fulfill({ + status: 200, + contentType: 'text/html', + body: '' + })); + const page = await context.newPage(); + await page.goto(ORIGIN + '/User/TwoFactor'); + assert.equal(await page.evaluate(() => window.isSecureContext), true, 'the test origin is a secure context'); + + const cdp = await context.newCDPSession(page); + await cdp.send('WebAuthn.enable'); + const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } + }); + + assert.equal(await page.evaluate(() => resgridPasskeys.isSupported()), true); + + // ---- Registration: the server's options in, base64url JSON out ---- + const createChallenge = crypto.randomBytes(32); + const registered = await page.evaluate((options) => resgridPasskeys.register(options), creationOptions(createChallenge)); + assert.equal(registered.type, 'public-key'); + assert.match(registered.id, B64URL); + assert.equal(registered.rawId, registered.id, 'rawId is the same bytes as id, base64url'); + assert.match(registered.response.attestationObject, B64URL); + const createData = decodeJson(registered.response.clientDataJSON); + assert.equal(createData.type, 'webauthn.create'); + assert.equal(createData.challenge, b64url(createChallenge), 'the challenge reached the authenticator unchanged'); + assert.equal(createData.origin, ORIGIN); + assert.ok(!('toJSON' in registered), 'plain JSON, ready to post'); + + // ---- Assertion with the passkey just made ---- + const getChallenge = crypto.randomBytes(32); + const asserted = await page.evaluate((options) => resgridPasskeys.authenticate(options), requestOptions(getChallenge, [registered.id])); + assert.equal(asserted.id, registered.id); + assert.match(asserted.response.authenticatorData, B64URL); + assert.match(asserted.response.signature, B64URL); + assert.equal(asserted.response.userHandle, b64url(Buffer.from('user-handle-1')), 'the user handle comes back as the server sent it'); + const getData = decodeJson(asserted.response.clientDataJSON); + assert.equal(getData.type, 'webauthn.get'); + assert.equal(getData.challenge, b64url(getChallenge)); + + // ---- A second passkey on the same authenticator is refused by the browser, and says so ---- + const duplicate = await page.evaluate((options) => resgridPasskeys.register(options).then(() => 'registered', (error) => error.outcome), + creationOptions(crypto.randomBytes(32), [registered.id])); + assert.equal(duplicate, 'already_registered'); + + // ---- A refused prompt is a cancellation, never a failed verification ---- + await cdp.send('WebAuthn.setUserVerified', { authenticatorId, isUserVerified: false }); + const cancelled = await page.evaluate((options) => resgridPasskeys.authenticate(options).then(() => 'verified', (error) => error.outcome), + requestOptions(crypto.randomBytes(32), [registered.id])); + assert.equal(cancelled, 'cancelled'); + + // ---- The hand-built JSON (browsers without toJSON) matches the same shape ---- + const manual = await page.evaluate(() => { + const bytes = (values) => new Uint8Array(values).buffer; + const assertion = resgridPasskeys.toJson({ + id: 'abc', rawId: bytes([251, 255, 254]), type: 'public-key', authenticatorAttachment: 'platform', + getClientExtensionResults: () => ({}), + response: { clientDataJSON: bytes([123, 125]), authenticatorData: bytes([1, 2]), signature: bytes([3]), userHandle: null } + }); + const attestation = resgridPasskeys.toJson({ + id: 'def', rawId: bytes([1]), type: 'public-key', getClientExtensionResults: () => ({ credProps: { rk: true } }), + response: { clientDataJSON: bytes([123, 125]), attestationObject: bytes([9, 9]), getTransports: () => ['internal'] } + }); + return { assertion, attestation }; + }); + assert.deepEqual(manual.assertion, { + id: 'abc', rawId: '-__-', type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform', + response: { clientDataJSON: 'e30', authenticatorData: 'AQI', signature: 'Aw', userHandle: null } + }, 'base64url without padding, with - and _'); + assert.deepEqual(manual.attestation.response, { clientDataJSON: 'e30', attestationObject: 'CQk', transports: ['internal'] }); + assert.deepEqual(manual.attestation.clientExtensionResults, { credProps: { rk: true } }); + + // ---- The option conversion leaves everything but the binary fields alone ---- + const converted = await page.evaluate((options) => { + const o = resgridPasskeys.creationOptions(options); + return { challenge: o.challenge instanceof ArrayBuffer, userId: new TextDecoder().decode(o.user.id), rp: o.rp.id, uv: o.authenticatorSelection.userVerification }; + }, creationOptions(crypto.randomBytes(8))); + assert.deepEqual(converted, { challenge: true, userId: 'user-handle-1', rp: 'web.resgrid.test', uv: 'required' }); + + // ---- A browser without WebAuthn is told so before any request is made ---- + const unsupported = await page.evaluate((options) => { + delete window.PublicKeyCredential; + window.PublicKeyCredential = undefined; + return resgridPasskeys.register(options).then(() => 'registered', (error) => error.outcome); + }, creationOptions(crypto.randomBytes(32))); + assert.equal(unsupported, 'not_supported'); + + console.log('resgrid-passkeys: all checks passed'); + } finally { + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/Tests/Resgrid.Tests/Web/resgrid-shared-session.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-shared-session.test.cjs new file mode 100644 index 000000000..fd77c7a28 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-shared-session.test.cjs @@ -0,0 +1,268 @@ +// Core Web on a shared workstation (passkey plan sections 10.5 and 12.5.4; workbook section 12, slice 25). Runs the real page +// scripts (the history guard, the workstation bar and the lock screen) in headless Chrome against a small server that plays +// the part of Resgrid's status and lock routes, with the page clock under the test's control. It shows that: +// - only real input reports operator activity, at most every 30 seconds across every tab of the site; +// - the page warns before the idle lock, and "Stay signed in" always reports; +// - when the server says the session is locked, every tab goes to the lock screen, and back once it is unlocked; +// - a page brought back with the back button stays hidden until the server confirms the session is unlocked; +// - a refused request, Lock and End shift take every tab with them. +// node Tests/Resgrid.Tests/Web/resgrid-shared-session.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const launch = require('./browser-launch.cjs'); +const { chromium } = launch.playwright(); +const root = path.resolve(__dirname, '../../..'); +const scripts = { + '/js/guard.js': fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.guard.js'), 'utf8'), + '/js/session.js': fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.session.js'), 'utf8'), + '/js/locked.js': fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.locked.js'), 'utf8'), + '/js/jquery.js': fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/lib/jquery/dist/jquery.min.js'), 'utf8') +}; + +// The workstation bar as _SharedSessionBar renders it, on an ordinary page with a link to another page. +function page(name) { + return ` +
+ +
+
+ +
+

${name}

+ + +`; +} + +// The lock screen as SharedSession/Locked renders it. +function lockedPage(returnUrl) { + return ` +

Workstation locked

+
+`; +} + +const state = { locked: false, ended: false, idle: 300, shift: 43200, delay: 0, lockedDelay: 0 }; +const seen = []; + +function statusBody() { + if (state.ended) + return [401, '']; + return [200, JSON.stringify({ shared: true, locked: state.locked, lockVersion: 1, idleLockMinutes: 5, + idleLocksInSeconds: state.locked ? null : state.idle, shiftEndsInSeconds: state.shift })]; +} + +const server = http.createServer((request, response) => { + const url = new URL(request.url, 'http://localhost'); + let body = ''; + request.on('data', chunk => body += chunk); + request.on('end', () => { + const entry = { method: request.method, path: url.pathname, query: url.search, activity: request.headers['x-resgrid-operator-activity'] === '1', body }; + seen.push(entry); + if (scripts[url.pathname]) { + response.writeHead(200, { 'Content-Type': 'text/javascript' }).end(scripts[url.pathname]); + } else if (url.pathname === '/page1' || url.pathname === '/page2') { + response.writeHead(200, { 'Content-Type': 'text/html' }).end(page(url.pathname.substring(1))); + } else if (url.pathname === '/SharedSession/Status') { + const [code, json] = statusBody(); + setTimeout(() => response.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(json), state.delay); + } else if (url.pathname === '/SharedSession/Locked') { + setTimeout(() => response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }).end(lockedPage(url.searchParams.get('returnUrl'))), + state.lockedDelay); + } else if (url.pathname === '/SharedSession/Lock') { + state.locked = true; + response.writeHead(302, { Location: '/SharedSession/Locked?returnUrl=' + encodeURIComponent(new URLSearchParams(body).get('returnUrl')) }).end(); + } else if (url.pathname === '/SharedSession/EndShift') { + state.ended = true; + response.writeHead(302, { Location: '/Account/LogOn' }).end(); + } else if (url.pathname === '/Account/LogOn') { + response.writeHead(200, { 'Content-Type': 'text/html' }).end('

Sign in

'); + } else if (url.pathname === '/refused') { + response.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'shared_session_locked', lock_version: 1 })); + } else { + response.writeHead(404).end(); + } + }); +}); + +const statuses = () => seen.filter(r => r.path === '/SharedSession/Status'); +const activities = () => statuses().filter(r => r.activity); + +async function eventually(check, message) { + for (let i = 0; i < 100; i++) { + if (await check()) + return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.fail(message); +} + +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = 'http://localhost:' + server.address().port; + const browser = await chromium.launch(launch.launchOptions()); + const reset = () => Object.assign(state, { locked: false, ended: false, idle: 300, shift: 43200, delay: 0, lockedDelay: 0 }); + + // The page clock is the test's, except where the browser's own navigation timing is needed: Playwright's fake clock + // replaces performance, which the history guard reads. + async function station(realClock) { + const context = await browser.newContext(); + if (!realClock) + await context.clock.install({ time: new Date('2026-09-29T12:00:00Z') }); + return context; + } + + async function open(context, where) { + const tab = await context.newPage(); + const before = statuses().length; + await tab.goto(base + where); + await eventually(async () => statuses().length > before && (await tab.textContent('#rgSharedCountdown')) !== '', 'the page asks the server first'); + return tab; + } + + try { + // ---- Activity: only real input, at most every 30 seconds across tabs; polling never counts ---- + reset(); + seen.length = 0; + let context = await station(); + const a = await open(context, '/page1'); + assert.equal(await a.textContent('#rgSharedCountdown'), 'Locks in 5:00'); + assert.equal(activities().length, 0, 'loading a page is not the operator'); + + await a.keyboard.press('a'); + await eventually(() => activities().length === 1, 'a key press is reported'); + await a.mouse.click(5, 5); + await a.keyboard.press('b'); + assert.equal(activities().length, 1, 'at most once per 30 seconds'); + + const b = await open(context, '/page1'); + state.idle = 250; + await a.clock.runFor(31000); + await a.keyboard.press('c'); + await eventually(() => activities().length === 2, 'reported again after 30 seconds'); + await eventually(async () => (await b.textContent('#rgSharedCountdown')) === 'Locks in 4:10', 'the other tab follows the new deadline'); + await b.keyboard.press('d'); + assert.equal(activities().length, 2, 'the interval is shared by every tab of the site'); + + const polls = statuses().length; + await a.clock.runFor(61000); + await eventually(() => statuses().length > polls, 'the page polls the server every minute'); + assert.ok(statuses().slice(polls).every(r => !r.activity), 'polling never counts as activity'); + + // ---- The warning before the idle lock, and Stay signed in ---- + state.idle = 65; + await a.clock.runFor(61000); + await eventually(async () => (await a.textContent('#rgSharedCountdown')).startsWith('Locks in 1:0'), 'the countdown follows the server'); + await a.clock.runFor(6000); + await eventually(async () => a.isVisible('#rgSharedWarning'), 'the warning shows before the lock'); + assert.match(await a.textContent('#rgSharedWarningText'), /^Locks in \d+ seconds$/); + // A key press reports activity; the server's answer still leaves the warning up, so Stay signed in is pressed + // well inside the 30-second interval, and must report anyway. + state.idle = 50; + const pressed = activities().length; + await a.keyboard.press('e'); + await eventually(() => activities().length === pressed + 1, 'the key press is reported'); + await eventually(async () => a.isVisible('#rgSharedWarning'), 'the warning is still up'); + state.idle = 300; + const stayed = activities().length; + await a.click('#rgSharedStay'); + await eventually(() => activities().length === stayed + 1, 'Stay signed in reports activity even within the 30-second interval'); + await eventually(async () => !(await a.isVisible('#rgSharedWarning')), 'and the warning goes away'); + + // ---- The server locks the session: every tab goes to the lock screen, and back once unlocked ---- + state.idle = 3; + await a.clock.runFor(61000); + state.locked = true; + await a.clock.runFor(5000); + await a.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + await b.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + + state.locked = false; + await a.goto(base + '/page2'); // the unlock sends this tab back; its page tells the others + await b.waitForURL(base + '/page1'); + await context.close(); + + // ---- The back button: hidden until the server confirms, and the lock screen if it is locked ---- + reset(); + context = await station(true); + const c = await open(context, '/page1'); + await c.click('#next'); + await eventually(async () => (await c.textContent('#content')) === 'page2', 'the next page loads'); + state.locked = true; + state.delay = 400; + await c.goBack(); + assert.equal(await c.evaluate(() => document.documentElement.classList.contains('rg-shared-concealed')), true, + 'a page from history is hidden before the server answers'); + assert.equal(await c.evaluate(() => getComputedStyle(document.body).visibility), 'hidden'); + await c.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + + state.locked = false; + state.delay = 0; + await c.goto(base + '/page1'); + await c.click('#next'); + await eventually(async () => (await c.textContent('#content')) === 'page2', 'the next page loads'); + await c.goBack(); + await eventually(async () => c.evaluate(() => !document.documentElement.classList.contains('rg-shared-concealed')), + 'an unlocked session shows the page again'); + assert.equal(c.url(), base + '/page1'); + await context.close(); + + // ---- A refused request, Lock and End shift take every tab with them ---- + reset(); + context = await station(); + const d = await open(context, '/page1'); + const e = await open(context, '/page2'); + // The page that sees the refusal tells the others at once, before its own lock screen has even loaded. + state.lockedDelay = 1500; + const others = e.waitForRequest((r) => r.url().includes('/SharedSession/Locked'), { timeout: 1000 }); + await d.evaluate(() => { $.get('/refused'); }); + await others; + await d.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + await e.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage2'); + state.lockedDelay = 0; + await d.goto(base + '/page1'); + await e.waitForURL(base + '/page2'); + + // A tab the server sends to the lock screen (a page load while locked) takes the others with it too. + await d.goto(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + await e.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage2'); + await d.goto(base + '/page1'); + await e.waitForURL(base + '/page2'); + + await e.click('#rgSharedLock'); + await e.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage2'); + await d.waitForURL(base + '/SharedSession/Locked?returnUrl=%2Fpage1'); + + await d.click('#sharedEndShift'); + await d.waitForURL(base + '/Account/LogOn'); + await e.waitForURL(base + '/Account/LogOn'); + await context.close(); + + // ---- A session that ended goes to sign-in; one past its shift says so ---- + reset(); + context = await station(); + const f = await open(context, '/page1'); + state.ended = true; + await f.clock.runFor(61000); + await f.waitForURL(base + '/Account/LogOn?returnUrl=%2Fpage1'); + + reset(); + state.shift = 2; + const g = await open(context, '/page1'); + state.ended = true; + await g.clock.runFor(3000); + await g.waitForURL(base + '/Account/LogOn?reason=shift_ended'); + await context.close(); + + console.log('resgrid-shared-session: all checks passed'); + } finally { + await browser.close(); + server.close(); + } +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/Tests/Resgrid.Tests/Web/resgrid-sso-return.test.cjs b/Tests/Resgrid.Tests/Web/resgrid-sso-return.test.cjs new file mode 100644 index 000000000..00efdaad2 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/resgrid-sso-return.test.cjs @@ -0,0 +1,129 @@ +// The return from the department's identity provider on Web (passkey plan section 7.7.2; workbook section 12). The Web's cookie +// policy makes every cookie SameSite=Strict, so the browser's arrival from the provider, a cross-site navigation, carries none of +// them. Account/SsoReturn's page reads nothing and posts the return on from this site: a same-site request that carries them. +// Runs the real view's markup and the real page script in headless Chrome across two sites (resgrid.test and idp.example). +// node Tests/Resgrid.Tests/Web/resgrid-sso-return.test.cjs (or through BrowserScriptTests / npm test) +// See browser-launch.cjs for RESGRID_PLAYWRIGHT_PATH and RESGRID_PLAYWRIGHT_CHANNEL. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const launch = require('./browser-launch.cjs'); +const { chromium } = launch.playwright(); +const root = path.resolve(__dirname, '../../..'); +const view = fs.readFileSync(path.join(root, 'Web/Resgrid.Web/Views/Account/SsoReturnContinue.cshtml'), 'utf8'); +const script = fs.readFileSync(path.join(root, 'Web/Resgrid.Web/wwwroot/js/app/common/sso/resgrid.sso.return.js'), 'utf8'); +const policy = "default-src 'none'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'"; + +// The view as Razor renders it for one return (it has no logic beyond these substitutions); anything left over is drift. +function render(code, state) { + const html = view + .replace(/^@(using|model|inject) .*\r?\n/gm, '') + .replace(/^@\{[\s\S]*?^\}\r?\n/m, '') + .replace('@Url.Action("SsoReturn", "Account")', '/Account/SsoReturn') + .replace(' asp-antiforgery="false"', '') + .replace(' asp-append-version="true"', '') + .replace('~/js/', '/js/') + .replace('@Model.Code', code).replace('@Model.State', state).replace('@Model.Error', '') + .replace('@localizer["SsoReturnContinuing"]', 'Returning').replace('@localizer["SsoReturnContinue"]', 'Continue'); + assert.ok(!html.includes('@') && !html.includes('asp-') && !html.includes('~/'), 'the test renders all of the view:\n' + html); + return html; +} + +function cookiesOf(request) { + return Object.fromEntries((request.headers.cookie || '').split(';').map(c => c.trim()).filter(Boolean).map(c => c.split('='))); +} + +(async () => { + const seen = []; + const server = http.createServer((request, response) => { + const host = (request.headers.host || '').split(':')[0]; + const url = new URL(request.url, 'http://' + request.headers.host); + let body = ''; + request.on('data', chunk => body += chunk); + request.on('end', () => { + seen.push({ host, method: request.method, path: url.pathname, cookies: cookiesOf(request), body }); + if (host === 'resgrid.test' && url.pathname === '/start') { + // The round trip, a signed-in session and a Lax control cookie, as this site set them before leaving. + response.setHeader('Set-Cookie', ['rt=trip; Path=/Account/SsoReturn; HttpOnly; SameSite=Strict', + 'auth=session; Path=/; HttpOnly; SameSite=Strict', 'lax=control; Path=/; SameSite=Lax']); + response.end('Sign in'); + } else if (host === 'idp.example' && url.pathname === '/authorize') { + response.end('Continue'); + } else if (host === 'idp.example' && url.pathname === '/callback') { + // The provider (by way of the broker) sends the browser back with only a code and the state. + response.writeHead(302, { Location: 'http://resgrid.test:' + port + '/Account/SsoReturn?sso_code=c1&state=s1' }).end(); + } else if (host === 'resgrid.test' && url.pathname === '/Account/SsoReturn' && request.method === 'GET') { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': policy, 'Cache-Control': 'no-store' }); + response.end(render(url.searchParams.get('sso_code'), url.searchParams.get('state'))); + } else if (host === 'resgrid.test' && url.pathname === '/js/app/common/sso/resgrid.sso.return.js') { + response.writeHead(200, { 'Content-Type': 'text/javascript' }).end(script); + } else if (host === 'resgrid.test' && url.pathname === '/Account/SsoReturn' && request.method === 'POST') { + response.writeHead(302, { Location: '/User/Home' }).end(); + } else if (host === 'resgrid.test' && url.pathname === '/User/Home') { + response.end('

Home

'); + } else { + response.writeHead(404).end(); + } + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + const browser = await chromium.launch({ + ...launch.launchOptions(), + args: ['--host-resolver-rules=MAP resgrid.test 127.0.0.1, MAP idp.example 127.0.0.1'] + }); + const find = (method, pathname) => seen.filter(r => r.host === 'resgrid.test' && r.method === method && r.path === pathname); + + async function signInThroughProvider(contextOptions) { + seen.length = 0; + const context = await browser.newContext(contextOptions); + const page = await context.newPage(); + const refused = []; + page.on('console', message => { if (/Content Security Policy|Refused/i.test(message.text())) refused.push(message.text()); }); + await page.goto('http://resgrid.test:' + port + '/start'); + await page.click('#go'); + await page.waitForURL('http://idp.example:' + port + '/authorize'); + await page.click('#done'); + return { context, page, refused }; + } + + try { + // ---- With script: the arrival carries no Strict cookie; the page's own post carries them all ---- + let { context, page, refused } = await signInThroughProvider({}); + await page.waitForURL('http://resgrid.test:' + port + '/User/Home'); + const arrival = find('GET', '/Account/SsoReturn'); + assert.equal(arrival.length, 1); + assert.equal(arrival[0].cookies.lax, 'control', 'a cross-site top-level navigation carries Lax cookies'); + assert.equal(arrival[0].cookies.rt, undefined, 'the provider\'s return carries no Strict cookie: the round trip is not here'); + assert.equal(arrival[0].cookies.auth, undefined, 'nor the session'); + const posted = find('POST', '/Account/SsoReturn'); + assert.equal(posted.length, 1, 'posted on once'); + assert.equal(posted[0].cookies.rt, 'trip', 'the page\'s own post carries the round trip'); + assert.equal(posted[0].cookies.auth, 'session', 'and the session'); + assert.deepEqual(Object.fromEntries(new URLSearchParams(posted[0].body)), { sso_code: 'c1', state: 's1', error: '' }); + assert.equal(find('GET', '/User/Home')[0].cookies.auth, 'session', 'where the return goes next is same-site too'); + assert.deepEqual(refused, [], 'the page script runs under the page\'s content security policy'); + await context.close(); + + // ---- Without script: the page's button posts it on the same way ---- + ({ context, page } = await signInThroughProvider({ javaScriptEnabled: false })); + await page.waitForURL('http://resgrid.test:' + port + '/Account/SsoReturn?sso_code=c1&state=s1'); + assert.equal(find('POST', '/Account/SsoReturn').length, 0, 'nothing is posted without script until the button is pressed'); + await page.click('button[type=submit]'); + await page.waitForURL('http://resgrid.test:' + port + '/User/Home'); + const pressed = find('POST', '/Account/SsoReturn'); + assert.equal(pressed.length, 1); + assert.equal(pressed[0].cookies.rt, 'trip'); + assert.equal(pressed[0].cookies.auth, 'session'); + await context.close(); + + console.log('resgrid-sso-return: all checks passed'); + } finally { + await browser.close(); + server.close(); + } +})().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/Tools/Resgrid.Console/Commands/AuthenticatorSeedsCommand.cs b/Tools/Resgrid.Console/Commands/AuthenticatorSeedsCommand.cs new file mode 100644 index 000000000..a5b72a040 --- /dev/null +++ b/Tools/Resgrid.Console/Commands/AuthenticatorSeedsCommand.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Resgrid.Config; +using Resgrid.Console.Models; +using Resgrid.Model.Repositories; +using Resgrid.Repositories.DataRepository.Stores; + +namespace Resgrid.Console.Commands +{ + /// + /// Encrypts or decrypts every stored authenticator seed (passkey workbook section 12, slice 14). + /// + /// --Encrypt encrypts plaintext seeds and re-encrypts seeds under a retired key with the active one. Run it after + /// turning TwoFactorConfig.AuthenticatorSeedEncryptionEnabled on everywhere, and after every key rotation, before the + /// old key leaves the ring. --Decrypt writes seeds back as plaintext, for rolling back below the build that encrypts + /// them; turn the gate off everywhere first. Both are safe to run again and never print a seed. + /// + /// + public sealed class AuthenticatorSeedsCommand( + ILogger logger, + IIdentityUserRepository users) : ICommandService + { + public async Task ExecuteMainAsync(string[] args, CancellationToken cancellationToken) + { + var encrypt = args.Contains("--Encrypt", StringComparer.OrdinalIgnoreCase); + var decrypt = args.Contains("--Decrypt", StringComparer.OrdinalIgnoreCase); + if (encrypt == decrypt) + { + logger.LogError("Pass exactly one of --Encrypt or --Decrypt."); + return ExitCode.Failed; + } + + var batch = 500; + var batchArg = args.FirstOrDefault(a => a.StartsWith("--BatchSize=", StringComparison.OrdinalIgnoreCase)); + if (batchArg != null && (!int.TryParse(batchArg.Substring("--BatchSize=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out batch) || + batch < 1)) + { + logger.LogError("--BatchSize must be a positive whole number."); + return ExitCode.Failed; + } + + try + { + logger.LogInformation(encrypt + ? $"Encrypting authenticator seeds with key {AuthenticatorSeedProtector.ActiveKeyId}..." + : "Decrypting authenticator seeds to plaintext..."); + var result = await AuthenticatorSeedMigrator.RunAsync(users, + encrypt ? AuthenticatorSeedMigration.Encrypt : AuthenticatorSeedMigration.Decrypt, batch, cancellationToken); + + logger.LogInformation($"Scanned {result.Scanned}; rewritten {result.Rewritten}; already done {result.Current}; " + + $"changed by their users meanwhile {result.ChangedMeanwhile}; unreadable {result.Unreadable}."); + if (result.Unreadable > 0) + { + logger.LogWarning("Unreadable seeds are under a key that is not configured, or damaged. Those users replace their authenticator " + + "or use a recovery code. Do not remove a key from the ring while seeds still use it."); + return ExitCode.Failed; + } + + return ExitCode.Success; + } + catch (InvalidOperationException ex) + { + logger.LogError(ex.Message); + return ExitCode.Failed; + } + catch (Exception ex) + { + logger.LogError("Authenticator seed migration stopped. Finished rows are kept; run it again to continue."); + logger.LogError(ex.ToString()); + return ExitCode.Failed; + } + } + } +} diff --git a/Tools/Resgrid.Console/Commands/HelpCommand.cs b/Tools/Resgrid.Console/Commands/HelpCommand.cs index 7edc213fe..d674c602b 100644 --- a/Tools/Resgrid.Console/Commands/HelpCommand.cs +++ b/Tools/Resgrid.Console/Commands/HelpCommand.cs @@ -21,6 +21,7 @@ public async Task ExecuteMainAsync(string[] args, CancellationToken ca logger.LogInformation("Resgrid Console Help"); logger.LogInformation("--AddHosts :: Adds a host to the Resgrid Console"); + logger.LogInformation("--AuthenticatorSeeds --Encrypt|--Decrypt [--BatchSize=500] :: Encrypts every stored TOTP seed (gate on) or writes them back as plaintext before a rollback (gate off)"); logger.LogInformation("--ClearCache -- --DepartmentId=1 :: Clears the cache for a department"); logger.LogInformation("--DbUpdate || --UpdateDb :: Updates the Resgrid Database"); logger.LogInformation("--FeatureFlags :: Reads and sets feature toggles. Sub commands:"); diff --git a/Tools/Resgrid.Console/Program.cs b/Tools/Resgrid.Console/Program.cs index 746ffd3a2..a4f61a02f 100644 --- a/Tools/Resgrid.Console/Program.cs +++ b/Tools/Resgrid.Console/Program.cs @@ -99,7 +99,10 @@ static async Task Main(string[] args) config.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); config.Lockout.MaxFailedAccessAttempts = 5; config.Lockout.AllowedForNewUsers = true; - }).AddDefaultTokenProviders().AddClaimsPrincipalFactory>(); + }).AddDefaultTokenProviders() + // One-time TOTP steps, as in the web hosts (passkey plan section 7.5 rule 8). + .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider) + .AddClaimsPrincipalFactory>(); services.AddKeyedTransient("ResetPasswordCommand"); services.AddKeyedTransient("AddHostsCommand"); @@ -112,6 +115,7 @@ static async Task Main(string[] args) services.AddKeyedTransient("OidcUpdateCommand"); services.AddKeyedTransient("SecurityRefreshCommand"); services.AddKeyedTransient("FeatureFlagsCommand"); + services.AddKeyedTransient("AuthenticatorSeedsCommand"); services.AddKeyedTransient("HelpCommand"); services.AddHostedService(); diff --git a/Tools/Resgrid.Console/Services/ApplicationHostedService.cs b/Tools/Resgrid.Console/Services/ApplicationHostedService.cs index 9c3b12561..b69823fad 100644 --- a/Tools/Resgrid.Console/Services/ApplicationHostedService.cs +++ b/Tools/Resgrid.Console/Services/ApplicationHostedService.cs @@ -35,6 +35,7 @@ public sealed class ApplicationHostedService : IHostedService, IDisposable private ICommandService _oidcUpdateCommand; private ICommandService _securityRefreshCommand; private ICommandService _featureFlagsCommand; + private ICommandService _authenticatorSeedsCommand; private ICommandService _helpCommand; // Cancellation token source used to submit a cancellation request. @@ -63,6 +64,7 @@ public ApplicationHostedService( [FromKeyedServices("OidcUpdateCommand")] ICommandService oidcUpdateCommand, [FromKeyedServices("SecurityRefreshCommand")] ICommandService securityRefreshCommand, [FromKeyedServices("FeatureFlagsCommand")] ICommandService featureFlagsCommand, + [FromKeyedServices("AuthenticatorSeedsCommand")] ICommandService authenticatorSeedsCommand, [FromKeyedServices("HelpCommand")] ICommandService helpCommand) { _hostApplicationLifetime = hostApplicationLifetime; @@ -78,6 +80,7 @@ public ApplicationHostedService( _oidcUpdateCommand = oidcUpdateCommand; _securityRefreshCommand = securityRefreshCommand; _featureFlagsCommand = featureFlagsCommand; + _authenticatorSeedsCommand = authenticatorSeedsCommand; _helpCommand = helpCommand; } @@ -211,6 +214,8 @@ private async Task ExecuteMainAsync(string[] args, CancellationToken c return await _securityRefreshCommand.ExecuteMainAsync(args, cancellationToken).ConfigureAwait(false); else if (args.Contains("--FeatureFlags") || args.Contains("--FeatureToggles") || args.Contains("--Toggles")) return await _featureFlagsCommand.ExecuteMainAsync(args, cancellationToken).ConfigureAwait(false); + else if (args.Contains("--AuthenticatorSeeds")) + return await _authenticatorSeedsCommand.ExecuteMainAsync(args, cancellationToken).ConfigureAwait(false); else { return await _helpCommand.ExecuteMainAsync(args, cancellationToken).ConfigureAwait(false); diff --git a/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs index 2b481c050..cb1add1ba 100644 --- a/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs +++ b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs @@ -9,7 +9,7 @@ namespace Resgrid.Web.Broker.Controllers { /// - /// Field-crypto endpoints for the application tier (behind WorkloadKeyMiddleware). The response + /// Field-crypto endpoints for the application tier (behind BrokerCredentialMiddleware). The response /// body is always a ProtectedDataBrokerResult; the HTTP status mirrors its error code so plain /// HTTP clients and infrastructure see failures too. No endpoint here exposes key material, a /// general unwrap, or any bulk/no-grant path. @@ -29,6 +29,8 @@ public BrokerController(BrokerOperationService operationService) public async Task> Decrypt([FromBody] BrokerFieldOperationRequest request, CancellationToken cancellationToken) { + AttachCaller(request); + AttachSessionAssertion(request); var result = await _operationService.DecryptAsync(request, cancellationToken); return StatusCode(MapStatusCode(result), result); } @@ -37,6 +39,8 @@ public async Task> Decrypt([FromBody] Br public async Task> Encrypt([FromBody] BrokerFieldOperationRequest request, CancellationToken cancellationToken) { + AttachCaller(request); + AttachSessionAssertion(request); var result = await _operationService.EncryptAsync(request, cancellationToken); return StatusCode(MapStatusCode(result), result); } @@ -49,10 +53,28 @@ public async Task> Encrypt([FromBody] Br public async Task> DecryptForWorkload([FromQuery] string purpose, [FromBody] BrokerFieldOperationRequest request, CancellationToken cancellationToken) { + AttachCaller(request); var result = await _operationService.DecryptForWorkloadAsync(request, purpose, cancellationToken); return StatusCode(MapStatusCode(result), result); } + /// The credential the middleware authenticated; the lanes it grants are enforced by the service. + private void AttachCaller(BrokerFieldOperationRequest request) + { + if (request != null) + request.Caller = HttpContext.Items[Middleware.BrokerCredentialMiddleware.CredentialItemKey] as BrokerCredential; + } + + /// The session assertion travels in a header; the workload lane never uses one. + private void AttachSessionAssertion(BrokerFieldOperationRequest request) + { + if (request == null) + return; + + string assertion = Request.Headers[Resgrid.Model.Security.BrokerSessionAssertion.HeaderName]; + request.SessionAssertion = string.IsNullOrWhiteSpace(assertion) ? null : assertion.Trim(); + } + private static int MapStatusCode(ProtectedDataBrokerResult result) { if (result.Success) @@ -68,8 +90,16 @@ private static int MapStatusCode(ProtectedDataBrokerResult result) return StatusCodes.Status409Conflict; case "grant_expired": case "grant_invalid": + case "grant_version_unsupported": + case "session_assertion_required": + case "session_assertion_invalid": + case "session_revoked": return StatusCodes.Status401Unauthorized; case "grant_revoked": + case "lane_denied": + case "grant_session_mismatch": + case "grant_client_mismatch": + case "grant_session_locked": case "workload_purpose_denied": return StatusCodes.Status403Forbidden; case "no_active_key": diff --git a/Web/Resgrid.Web.Broker/Middleware/BrokerCredentialMiddleware.cs b/Web/Resgrid.Web.Broker/Middleware/BrokerCredentialMiddleware.cs new file mode 100644 index 000000000..11917d49c --- /dev/null +++ b/Web/Resgrid.Web.Broker/Middleware/BrokerCredentialMiddleware.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Resgrid.Web.Broker.Services; + +namespace Resgrid.Web.Broker.Middleware +{ + /// + /// Authenticates the calling host (passkey plan section 8.5), under network isolation and, later, mTLS. A host + /// presents its credential id in X-Resgrid-Broker-Client and its key in X-Resgrid-Broker-Key; the matched + /// credential travels with the request so the operation service can enforce its lanes. Without a client id, only + /// the legacy shared key is recognized, and only while it is still accepted; every such use is logged with the + /// calling host. Nothing configured refuses everything (503); a wrong or unknown credential is 401 with no detail. + /// /health is exempt for the k8s probes. + /// + public class BrokerCredentialMiddleware + { + public const string ClientHeader = "X-Resgrid-Broker-Client"; + public const string KeyHeader = "X-Resgrid-Broker-Key"; + + /// Informational only (never trusted): the calling process, so legacy-key logs can name the host. + public const string HostHeader = "X-Resgrid-Broker-Host"; + + /// HttpContext.Items key for the authenticated . + public const string CredentialItemKey = "Resgrid.BrokerCredential"; + + private readonly RequestDelegate _next; + + public BrokerCredentialMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, BrokerCredentialRegistry registry) + { + if (context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + if (!registry.HasCredentials && !BrokerCredentialRegistry.LegacyKeyAccepted) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + + var clientId = context.Request.Headers[ClientHeader].ToString().Trim(); + var key = context.Request.Headers[KeyHeader].ToString(); + + BrokerCredential credential; + if (clientId.Length > 0) + { + credential = registry.Authenticate(clientId, key); + if (credential == null) + Framework.Logging.LogError($"Protected Data Broker refused credential '{Sanitize(clientId)}' from {RemoteHost(context)}: unknown id or wrong key."); + } + else + { + credential = BrokerCredentialRegistry.IsLegacyKey(key) ? BrokerCredential.Legacy() : null; + if (credential != null) + Framework.Logging.LogInfo($"Protected Data Broker legacy shared key used by {RemoteHost(context)} " + + $"(host '{Sanitize(context.Request.Headers[HostHeader].ToString())}', {context.Request.Method} {context.Request.Path}). " + + "Issue this host its own credential; the shared key is retired at the end of the migration window."); + } + + if (credential == null) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + context.Items[CredentialItemKey] = credential; + await _next(context); + } + + private static string RemoteHost(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + // Header values are caller-controlled: keep log lines to a short, printable token. + private static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + return "-"; + var chars = value.Length > 64 ? value[..64].ToCharArray() : value.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + if (!char.IsLetterOrDigit(chars[i]) && chars[i] != '-' && chars[i] != '.' && chars[i] != '_') + chars[i] = '_'; + return new string(chars); + } + } +} diff --git a/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs b/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs deleted file mode 100644 index b575ee06b..000000000 --- a/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Resgrid.Config; - -namespace Resgrid.Web.Broker.Middleware -{ - /// - /// Application-tier workload gate (ADP plan section 2.2): every broker API request must present - /// the shared workload key in X-Resgrid-Broker-Key. This is defense-in-depth UNDER network - /// isolation and transport-level mTLS, never the only control. An unconfigured key refuses - /// everything (503, fail closed); a wrong key is 401 with no detail. Comparison is - /// constant-time. /health is exempt for the k8s probes. - /// - public class WorkloadKeyMiddleware - { - private readonly RequestDelegate _next; - - public WorkloadKeyMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - if (context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase)) - { - await _next(context); - return; - } - - var configuredKey = DataProtectionConfig.BrokerApiKey; - if (string.IsNullOrWhiteSpace(configuredKey)) - { - context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - return; - } - - var presentedKey = context.Request.Headers["X-Resgrid-Broker-Key"].ToString(); - if (string.IsNullOrEmpty(presentedKey) || !FixedTimeEquals(presentedKey, configuredKey)) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - await _next(context); - } - - private static bool FixedTimeEquals(string presented, string configured) - { - var presentedBytes = Encoding.UTF8.GetBytes(presented); - var configuredBytes = Encoding.UTF8.GetBytes(configured); - return CryptographicOperations.FixedTimeEquals(presentedBytes, configuredBytes); - } - } -} diff --git a/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs b/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs index 5f86b2f33..3a9d1a870 100644 --- a/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs +++ b/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs @@ -19,5 +19,21 @@ public class BrokerFieldOperationRequest public string RequestId { get; set; } public List Items { get; set; } + + /// + /// The caller's session assertion (passkey workbook section 6.2). Read from the X-Resgrid-Session-Assertion header + /// by the controller, never from the request body. + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + public string SessionAssertion { get; set; } + + /// + /// The calling host's authenticated credential (passkey plan section 8.5), set by the controller from the + /// credential middleware, never from the request body. A request without one is refused. + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + public Services.BrokerCredential Caller { get; set; } } } diff --git a/Web/Resgrid.Web.Broker/Services/BrokerCredentials.cs b/Web/Resgrid.Web.Broker/Services/BrokerCredentials.cs new file mode 100644 index 000000000..8ed872b20 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Services/BrokerCredentials.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Resgrid.Config; + +namespace Resgrid.Web.Broker.Services +{ + /// The broker lanes (passkey plan section 8.5). Every request is classified into exactly one. + public enum BrokerLane + { + /// decrypt or encrypt with a user's Protected Data Grant. + Attended = 1, + + /// workload/decrypt with an allow-listed purpose, and grant-less encrypt. + Workload = 2, + + /// decrypt with a one-use adpr. release receipt. + Receipt = 3 + } + + /// + /// One calling host's broker identity: the lanes it may use and, for the workload lane, its purposes. The legacy + /// shared key maps to , which keeps the full authority it had until the key is retired. + /// + public sealed class BrokerCredential + { + public const string LegacyId = "legacy"; + + public string Id { get; init; } + public IReadOnlySet Lanes { get; init; } + public IReadOnlySet WorkloadPurposes { get; init; } + public bool IsLegacy { get; init; } + + /// The value-free audit layer for this credential and lane, e.g. broker/api/attended (fits 32 characters). + public string AuditLayer(BrokerLane lane) => $"broker/{Id}/{LaneName(lane)}"; + + public bool Allows(BrokerLane lane) => Lanes.Contains(lane); + + /// A workload purpose must be granted to this credential and remain on the broker's global allow-list. + public bool AllowsPurpose(string purpose) => + Allows(BrokerLane.Workload) && !string.IsNullOrEmpty(purpose) && WorkloadPurposes.Contains(purpose) && + BrokerCredentialRegistry.GlobalPurposes().Contains(purpose); + + /// The legacy shared key: every lane and every globally allowed purpose, for the migration window only. + public static BrokerCredential Legacy() => new() + { + Id = LegacyId, + IsLegacy = true, + Lanes = new HashSet { BrokerLane.Attended, BrokerLane.Workload, BrokerLane.Receipt }, + WorkloadPurposes = BrokerCredentialRegistry.GlobalPurposes() + }; + + public static string LaneName(BrokerLane lane) => lane switch + { + BrokerLane.Attended => "attended", + BrokerLane.Workload => "workload", + BrokerLane.Receipt => "receipt", + _ => "unknown" + }; + } + + /// + /// Parses and validates DataProtectionConfig.BrokerClientCredentials (passkey plan section 8.5 rules 1-3) and + /// authenticates callers. Keys exist here only as SHA-256 hashes and are compared in constant time; up to two hashes + /// per credential allow rotation. Any problem makes the map invalid, and the broker refuses to start. + /// + public sealed class BrokerCredentialRegistry + { + private static readonly Regex IdPattern = new("^[a-z0-9][a-z0-9-]{0,15}$", RegexOptions.Compiled); + private static readonly Regex HashPattern = new("^[0-9a-fA-F]{64}$", RegexOptions.Compiled); + + private readonly Dictionary _credentials; + + public BrokerCredentialRegistry() : this(DataProtectionConfig.BrokerClientCredentials) + { + } + + public BrokerCredentialRegistry(string configuration) + { + var problems = new List(); + _credentials = Parse(configuration, problems); + Problems = problems; + } + + public IReadOnlyList Problems { get; } + + public bool IsValid => Problems.Count == 0; + + public bool HasCredentials => IsValid && _credentials.Count > 0; + + public IEnumerable CredentialIds => _credentials.Keys; + + /// True when the legacy shared key is still accepted and configured. + public static bool LegacyKeyAccepted => + DataProtectionConfig.BrokerLegacySharedKeyEnabled && !string.IsNullOrWhiteSpace(DataProtectionConfig.BrokerApiKey); + + /// The credential whose id and key match; null otherwise. Always hashes, then compares in constant time. + public BrokerCredential Authenticate(string clientId, string presentedKey) + { + var presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(presentedKey ?? string.Empty)); + if (!IsValid || string.IsNullOrEmpty(presentedKey) || clientId == null || + !_credentials.TryGetValue(clientId, out var entry)) + return null; + + var matched = false; + foreach (var hash in entry.KeyHashes) + matched |= CryptographicOperations.FixedTimeEquals(presentedHash, hash); + + return matched ? entry.Credential : null; + } + + /// Constant-time check of the legacy shared key, when it is still accepted. + public static bool IsLegacyKey(string presentedKey) + { + if (!LegacyKeyAccepted || string.IsNullOrEmpty(presentedKey)) + return false; + + return CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(presentedKey)), + SHA256.HashData(Encoding.UTF8.GetBytes(DataProtectionConfig.BrokerApiKey))); + } + + /// The broker's global workload-purpose allow-list (DataProtectionConfig.BrokerWorkloadPurposes). + public static IReadOnlySet GlobalPurposes() => + new HashSet((DataProtectionConfig.BrokerWorkloadPurposes ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => p.ToLowerInvariant()), StringComparer.Ordinal); + + private static Dictionary Parse(string configuration, List problems) + { + var result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(configuration)) + return result; + + var globalPurposes = GlobalPurposes(); + var seenHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in configuration.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var equals = entry.IndexOf('='); + var fields = equals > 0 ? entry[(equals + 1)..].Split('|') : Array.Empty(); + if (equals <= 0 || fields.Length != 3) + { + problems.Add("A broker credential entry is not in the form id=lanes|purposes|keyHash[,nextKeyHash]."); + continue; + } + + var id = entry[..equals].Trim(); + if (!IdPattern.IsMatch(id) || id == BrokerCredential.LegacyId) + { + problems.Add($"Broker credential id '{id}' must be 1-16 lowercase letters, digits or hyphens, and not '{BrokerCredential.LegacyId}'."); + continue; + } + + if (result.ContainsKey(id)) + { + problems.Add($"Broker credential '{id}' is listed more than once."); + continue; + } + + var lanes = new HashSet(); + var laneProblem = false; + foreach (var lane in Items(fields[0])) + { + switch (lane) + { + case "attended": lanes.Add(BrokerLane.Attended); break; + case "workload": lanes.Add(BrokerLane.Workload); break; + case "receipt": lanes.Add(BrokerLane.Receipt); break; + default: + problems.Add($"Broker credential '{id}' names an unknown lane '{lane}'."); + laneProblem = true; + break; + } + } + + if (lanes.Count == 0 && !laneProblem) + problems.Add($"Broker credential '{id}' has no lanes."); + + var purposes = new HashSet(Items(fields[1]).Select(p => p.ToLowerInvariant()), StringComparer.Ordinal); + if (purposes.Count > 0 && !lanes.Contains(BrokerLane.Workload)) + problems.Add($"Broker credential '{id}' lists workload purposes without the workload lane."); + foreach (var purpose in purposes.Where(p => !globalPurposes.Contains(p))) + problems.Add($"Broker credential '{id}' names purpose '{purpose}', which is not in BrokerWorkloadPurposes."); + + var hashes = Items(fields[2]).ToList(); + if (hashes.Count is < 1 or > 2 || hashes.Any(h => !HashPattern.IsMatch(h))) + { + problems.Add($"Broker credential '{id}' needs one or two 64-character hex SHA-256 key hashes (current, then next)."); + continue; + } + + foreach (var hash in hashes) + { + if (!seenHashes.Add(hash)) + problems.Add($"Broker credential '{id}' reuses a key hash; every host needs its own key."); + } + + result[id] = (new BrokerCredential + { + Id = id, + Lanes = lanes, + WorkloadPurposes = purposes + }, hashes.Select(Convert.FromHexString).ToArray()); + } + + return result; + } + + private static IEnumerable Items(string field) => + field.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs index 0ed7403cf..68be366f7 100644 --- a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -5,12 +5,13 @@ using System.Threading; using System.Threading.Tasks; using Autofac; -using Microsoft.Extensions.Caching.Memory; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Security; using Resgrid.Model.Services; +using Resgrid.Services; using Resgrid.Web.Broker.Models; namespace Resgrid.Web.Broker.Services @@ -22,32 +23,37 @@ namespace Resgrid.Web.Broker.Services /// key material, and emit a value-free audit line. Every failure is closed — a request-level /// fault processes NO items, and item-level faults return error codes, never partial values. /// Plaintext and ciphertext values are never logged. + /// + /// Attended requests are also bound to the end user's live session (passkey workbook section 6.2): + /// the calling host's session assertion is verified, must agree with the grant, is single use, and + /// the session it names must still validate. Replay records live in a shared store, so every + /// broker replica refuses a replay. /// public class BrokerOperationService { - // Replayed request ids are refused for this long; grants outlive it, so a replayed id can - // never slip back in while its grant is still valid. - private static readonly TimeSpan ReplayWindow = TimeSpan.FromMinutes(15); - private readonly ILifetimeScope _rootScope; private readonly IProtectedDataGrantService _grantService; private readonly IProtectedFieldCryptoService _cryptoService; private readonly IKeyWrappingProvider _keyWrappingProvider; - private readonly IMemoryCache _replayCache; private readonly IAdpAuditRepository _audit; + private readonly IBrokerSessionAssertionService _assertions; public BrokerOperationService(ILifetimeScope rootScope, IProtectedDataGrantService grantService, IProtectedFieldCryptoService cryptoService, IKeyWrappingProvider keyWrappingProvider, - IMemoryCache replayCache, IAdpAuditRepository audit) + IAdpAuditRepository audit, IBrokerSessionAssertionService assertions) { _rootScope = rootScope; _grantService = grantService; _cryptoService = cryptoService; _keyWrappingProvider = keyWrappingProvider; - _replayCache = replayCache; _audit = audit; + _assertions = assertions; } + // Replayed ids are refused for at least this long; grants outlive it, so a replayed id can never slip + // back in while its grant is still valid. + private static TimeSpan ReplayWindow => TimeSpan.FromMinutes(Math.Max(15, Config.DataProtectionConfig.BrokerReplayWindowMinutes)); + public Task DecryptAsync(BrokerFieldOperationRequest request, CancellationToken cancellationToken) => ProcessAsync(request, decrypt: true, cancellationToken); @@ -82,41 +88,81 @@ private async Task ProcessAsync(BrokerFieldOperationR CancellationToken cancellationToken, string workloadPurpose = null) { if (request == null || request.DepartmentId <= 0) return Fail("invalid_request"); + + // Only an authenticated host reaches here (BrokerCredentialMiddleware); anything else is refused outright. + var caller = request.Caller; + if (caller == null) return Fail("lane_denied"); + var operation = workloadPurpose != null ? "workload-decrypt" : decrypt ? "decrypt" : "encrypt"; + var lane = Classify(request, decrypt, workloadPurpose); + var layer = lane == null ? $"broker/{caller.Id}/refused" : caller.AuditLayer(lane.Value); try { - await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = layer, Operation = operation, Outcome = "requested", CorrelationId = request.RequestId }, cancellationToken); - var result = await ProcessCoreAsync(request, decrypt, cancellationToken, workloadPurpose); - await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", - Operation = operation, Outcome = result.Success ? "completed" : "denied", CorrelationId = request.RequestId }, cancellationToken); + + // The lane gate runs before anything is consumed: no request id burned, no grant or receipt read, + // no key touched. A refusal never falls through to another lane. + var result = lane == null || !caller.Allows(lane.Value) + ? Fail("lane_denied") + : await ProcessCoreAsync(request, decrypt, cancellationToken, workloadPurpose, layer); + + // A purpose the broker allows but this host was not granted is a cross-lane attempt too; a purpose off + // the global list, or a department not actively protected, is an ordinary refusal. + var laneRefused = result.ErrorCode == "lane_denied" || + result.ErrorCode == "workload_purpose_denied" && IsAllowedWorkloadPurpose(workloadPurpose) && !caller.AllowsPurpose(workloadPurpose); + if (laneRefused) + Logging.LogError($"ADP broker refused a cross-lane request: credential {caller.Id}, lane {(lane == null ? "none" : BrokerCredential.LaneName(lane.Value))}, " + + $"operation {operation}, department {request.DepartmentId}{(workloadPurpose == null ? string.Empty : $", purpose {workloadPurpose}")}."); + + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = layer, + Operation = operation, Outcome = result.Success ? "completed" : laneRefused ? "lane-denied" : "denied", + CorrelationId = request.RequestId }, cancellationToken); return result; } catch (OperationCanceledException) { throw; } catch (Exception) { return Fail("audit_unavailable"); } } + /// + /// The one lane a request belongs to (passkey plan section 8.5): workload decrypt takes a purpose and never a + /// token; decrypt with an adpr. receipt is the receipt lane; encrypt without a token is the workload lane; any other + /// decrypt or encrypt is attended. Null means the request fits no lane. + /// + public static BrokerLane? Classify(BrokerFieldOperationRequest request, bool decrypt, string workloadPurpose) + { + var hasToken = !string.IsNullOrWhiteSpace(request.GrantToken); + if (workloadPurpose != null) + return hasToken ? null : BrokerLane.Workload; + if (decrypt) + return hasToken && request.GrantToken.StartsWith("adpr.", StringComparison.Ordinal) ? BrokerLane.Receipt : BrokerLane.Attended; + return hasToken ? BrokerLane.Attended : BrokerLane.Workload; + } + private async Task ProcessCoreAsync(BrokerFieldOperationRequest request, bool decrypt, - CancellationToken cancellationToken, string workloadPurpose) + CancellationToken cancellationToken, string workloadPurpose, string layer) { if (request == null || request.DepartmentId <= 0 || string.IsNullOrWhiteSpace(request.RequestId) || request.Items == null || request.Items.Count == 0) return Fail("invalid_request"); - // Workload lane: the purpose gate runs before anything is consumed (no request id burned, no key touched). - if (workloadPurpose != null && !IsAllowedWorkloadPurpose(workloadPurpose)) + // Workload lane: the purpose must be on the broker's allow-list AND granted to this host's credential, and + // the gate runs before anything is consumed (no request id burned, no key touched). + if (workloadPurpose != null && (!IsAllowedWorkloadPurpose(workloadPurpose) || !request.Caller.AllowsPurpose(workloadPurpose))) return Fail("workload_purpose_denied"); var maxItems = Math.Max(1, Config.DataProtectionConfig.BrokerMaxItemsPerRequest); if (request.Items.Count > maxItems) return Fail("too_many_items"); - // Replay: a request id is single-use per department (plan section 2.2). - var replayKey = $"adp-broker-request:{request.DepartmentId}:{request.RequestId}"; - if (!TryClaimRequestId(replayKey)) - return Fail("replayed_request"); - using var scope = _rootScope.BeginLifetimeScope(); + + // Replay: a request id is single-use per department (plan section 2.2), across every broker replica. + var claim = await TryClaimAsync(scope, $"adp-broker-request:{request.DepartmentId}:{request.RequestId}", + BrokerReplayKind.RequestId, cancellationToken); + if (claim != null) + return Fail(claim); + var policyRepository = scope.Resolve(); var keyService = scope.Resolve(); @@ -151,9 +197,14 @@ await receipts.ConsumeAsync(request.GrantToken, request.DepartmentId, currentEpo requiredScope, out grant); if (outcome != ProtectedDataGrantValidationOutcome.Valid) return Fail(MapGrantOutcome(outcome)); + + // A failed session check never falls through to the workload lane. + var sessionRefusal = await CheckAttendedSessionAsync(scope, request, grant, policy, decrypt, cancellationToken); + if (sessionRefusal != null) + return Fail(sessionRefusal); } - await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = "broker", + await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId, Layer = layer, Operation = decrypt ? "decrypt-authorized" : "encrypt-authorized", Outcome = "authorized", ActorId = grant?.UserId, ResourceId = grant?.GrantId, CorrelationId = request.RequestId, PolicyEpoch = currentEpoch }, cancellationToken); @@ -178,7 +229,7 @@ await _audit.AppendAsync(new AdpAuditEvent { DepartmentId = request.DepartmentId CryptographicOperations.ZeroMemory(dek); } - Audit(workloadPurpose != null ? "workload-decrypt" : decrypt ? "decrypt" : "encrypt", request, grant, result, workloadPurpose); + Audit(workloadPurpose != null ? "workload-decrypt" : decrypt ? "decrypt" : "encrypt", request, grant, result, layer, workloadPurpose); return result; } @@ -381,15 +432,97 @@ private async Task ResolveKeyAsync(int departmentId, int keyVersion, IDe return dek; } - private bool TryClaimRequestId(string replayKey) + /// + /// Binds an attended request to the end user's live session (passkey workbook section 6.2); null when it may + /// proceed, otherwise the value-free refusal. A version 2 grant always needs a valid assertion. A version 1 + /// grant needs one only once BrokerRequireSessionAssertion is on, but an assertion that is presented + /// must be valid either way. + /// + private async Task CheckAttendedSessionAsync(ILifetimeScope scope, BrokerFieldOperationRequest request, + ProtectedDataGrant grant, DepartmentDataProtectionPolicy policy, bool decrypt, CancellationToken cancellationToken) { - lock (_replayCache) + var required = grant.Version >= 2 || Config.DataProtectionConfig.BrokerRequireSessionAssertion; + if (string.IsNullOrWhiteSpace(request.SessionAssertion)) + return required ? "session_assertion_required" : null; + + var digest = BrokerRequestDigest.Compute(decrypt ? "decrypt" : "encrypt", request.DepartmentId, request.RequestId, request.Items); + var outcome = _assertions.Validate(request.SessionAssertion, digest, out var assertion); + if (outcome == BrokerSessionAssertionOutcome.NotConfigured) + return required ? "session_assertion_unavailable" : null; + if (outcome != BrokerSessionAssertionOutcome.Valid) + return "session_assertion_invalid"; + + var claim = await TryClaimAsync(scope, "adp-broker-assertion:" + assertion.AssertionId, + BrokerReplayKind.SessionAssertion, cancellationToken); + if (claim != null) + return claim; + + // The assertion and the grant must describe the same user and department; a version 2 grant must also + // match the asserted session, client, generation and lock version exactly. + if (!string.Equals(assertion.UserId, grant.UserId, StringComparison.OrdinalIgnoreCase) || + assertion.DepartmentId != request.DepartmentId) + return "grant_session_mismatch"; + + var binding = await ProtectedGrantBinding.CheckAsync(grant, assertion.UserId, new ProtectedGrantSessionContext { - if (_replayCache.TryGetValue(replayKey, out _)) - return false; + SessionId = assertion.SessionId, + ClientApplication = assertion.ClientApplication, + AuthenticationGeneration = assertion.AuthenticationGeneration, + SessionLockVersion = assertion.SessionLockVersion + }, policy?.StepUpWindowMinutes, scope.ResolveOptional(), cancellationToken); + if (binding != ProtectedGrantBindingOutcome.Bound) + return ProtectedGrantBinding.ErrorCode(binding); + + // The live session: active, unexpired, same generation, department membership, idle timeout, and the + // credential cutoff checked with the issue time the calling host validated. + SessionValidationResult validation; + try + { + validation = await scope.Resolve().ValidateAsync(new SessionPrincipalContext + { + UserId = assertion.UserId, + SessionId = assertion.SessionId, + AuthenticationGeneration = assertion.AuthenticationGeneration, + DepartmentId = assertion.DepartmentId, + CredentialIssuedOn = assertion.CredentialIssuedOnUtc + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogError($"ADP broker session validation failed closed for department {request.DepartmentId}: {ex.GetType().Name}."); + return "session_validation_unavailable"; + } + + if (validation.IsLocked) + return SharedSessionRules.LockedFailureCode; + if (!validation.IsValid || validation.Session == null) + return "session_revoked"; + if (validation.Session.ClientApplication != assertion.ClientApplication) + return "grant_client_mismatch"; + + // The assertion's lock version must still be the session's (plan section 12.5.3): an assertion or grant minted + // before a lock stays unusable after the unlock, even though both still agree with each other. + if (SharedSessionRules.LockVersionOf(validation.Session) != assertion.SessionLockVersion) + return "grant_session_locked"; - _replayCache.Set(replayKey, true, ReplayWindow); - return true; + return null; + } + + /// Claims a replay key in the shared store: null when claimed, otherwise the refusal. Faults refuse. + private async Task TryClaimAsync(ILifetimeScope scope, string key, BrokerReplayKind kind, CancellationToken cancellationToken) + { + var digest = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key))); + var now = DateTime.UtcNow; + try + { + var claimed = await scope.Resolve().TryClaimAsync(digest, kind, now.Add(ReplayWindow), now, + cancellationToken); + return claimed ? null : "replayed_request"; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogError($"ADP broker replay store failed closed: {ex.GetType().Name}."); + return "replay_store_unavailable"; } } @@ -403,6 +536,8 @@ private static string MapGrantOutcome(ProtectedDataGrantValidationOutcome outcom return "grant_expired"; case ProtectedDataGrantValidationOutcome.EpochRevoked: return "grant_revoked"; + case ProtectedDataGrantValidationOutcome.VersionUnsupported: + return "grant_version_unsupported"; default: return "grant_invalid"; } @@ -413,12 +548,12 @@ private static ProtectedDataBrokerResult Fail(string errorCode) => /// Value-free audit line: identifiers and counts only, never field values. private static void Audit(string operation, BrokerFieldOperationRequest request, ProtectedDataGrant grant, - ProtectedDataBrokerResult result, string workloadPurpose = null) + ProtectedDataBrokerResult result, string layer, string workloadPurpose = null) { var failed = result.Items.Count(i => i.ErrorCode != null); var fields = string.Join(",", request.Items.Where(i => i?.FieldId != null).Select(i => i.FieldId).Distinct()); var identity = grant == null ? (workloadPurpose == null ? "workload" : $"workload purpose {workloadPurpose}") : $"user {grant.UserId}, grant {grant.GrantId}"; - Logging.LogInfo($"ADP broker {operation}: department {request.DepartmentId}, {identity}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); + Logging.LogInfo($"ADP broker {operation} ({layer}): department {request.DepartmentId}, {identity}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); } } } diff --git a/Web/Resgrid.Web.Broker/Startup.cs b/Web/Resgrid.Web.Broker/Startup.cs index ba6b51cb7..e33d4f51d 100644 --- a/Web/Resgrid.Web.Broker/Startup.cs +++ b/Web/Resgrid.Web.Broker/Startup.cs @@ -105,6 +105,9 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new ProtectedDataProviderModule()); builder.RegisterType().AsSelf().SingleInstance(); + + // Per-host credentials and their lanes (passkey plan section 8.5), parsed once from configuration. + builder.Register(_ => new BrokerCredentialRegistry()).AsSelf().SingleInstance(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) @@ -118,8 +121,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseRouting(); - // Workload gate for every broker API call; /health stays open for k8s probes. - app.UseMiddleware(); + // Per-host credential gate for every broker API call; /health stays open for k8s probes. + app.UseMiddleware(); app.UseEndpoints(endpoints => { @@ -147,9 +150,21 @@ private void ValidateCryptoConfiguration() Framework.Logging.LogError( "Protected Data Broker: no grant validation certificate is configured (DataProtectionConfig.GrantValidationCertificatePath). Every attended field-crypto request will be refused until one is provided."); - if (string.IsNullOrWhiteSpace(DataProtectionConfig.BrokerApiKey)) + // An invalid credential map is a configuration error, not a degraded mode: refuse to start (plan section 8.5 rule 3). + var credentials = AutofacContainer.Resolve(); + if (!credentials.IsValid) + throw new InvalidOperationException("Protected Data Broker: DataProtectionConfig.BrokerClientCredentials is invalid. " + + string.Join(" ", credentials.Problems)); + + if (!credentials.HasCredentials && !BrokerCredentialRegistry.LegacyKeyAccepted) Framework.Logging.LogError( - "Protected Data Broker: DataProtectionConfig.BrokerApiKey is empty. Every request will be refused (503) until the workload key is provided."); + "Protected Data Broker: no client credentials are configured (DataProtectionConfig.BrokerClientCredentials) and the legacy shared key is not accepted. Every request will be refused (503)."); + else if (BrokerCredentialRegistry.LegacyKeyAccepted) + Framework.Logging.LogInfo( + $"Protected Data Broker: the legacy shared key is still accepted with full authority (migration window). Per-host credentials: {string.Join(", ", credentials.CredentialIds)}."); + else + Framework.Logging.LogInfo( + $"Protected Data Broker: per-host credentials only ({string.Join(", ", credentials.CredentialIds)}); the legacy shared key is retired."); } } } diff --git a/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs index a33f105a1..b22407c94 100644 --- a/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs +++ b/Web/Resgrid.Web.Eventing/Middleware/SessionValidationHubFilter.cs @@ -17,10 +17,12 @@ namespace Resgrid.Web.Eventing.Middleware public class SessionValidationHubFilter : IHubFilter { private readonly IUserSessionService _userSessionService; + private readonly Resgrid.Services.SessionConnectionRegistry _connections; - public SessionValidationHubFilter(IUserSessionService userSessionService) + public SessionValidationHubFilter(IUserSessionService userSessionService, Resgrid.Services.SessionConnectionRegistry connections) { _userSessionService = userSessionService; + _connections = connections; } public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, @@ -35,11 +37,54 @@ public async ValueTask InvokeMethodAsync(HubInvocationContext invocation return await next(invocationContext); } - public Task OnConnectedAsync(HubLifetimeContext context, Func next) => - next(context); + /// + /// A connection with a user session joins that session's group, for events meant for it alone, and is tracked so the + /// sweep can close it once the session ends or locks (slice 16). + /// + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + var sessionId = SessionIdOf(context.Context.User); + if (sessionId == null) + { + await next(context); + return; + } + + _connections.Register(context.Context.ConnectionId, sessionId, context.Context.Abort); + try + { + await context.Hub.Groups.AddToGroupAsync(context.Context.ConnectionId, SessionEvents.GroupFor(sessionId)); + await next(context); + } + catch + { + // SignalR never calls OnDisconnectedAsync for a connection whose OnConnectedAsync failed. + _connections.Unregister(context.Context.ConnectionId); + throw; + } + } public Task OnDisconnectedAsync(HubLifetimeContext context, Exception exception, - Func next) => next(context, exception); + Func next) + { + _connections.Unregister(context.Context.ConnectionId); + return next(context, exception); + } + + /// The tracked user session a connection belongs to; null for workloads and pre-session tokens. + private static string SessionIdOf(ClaimsPrincipal principal) + { + if (principal?.Identity?.IsAuthenticated != true) + return null; + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || userId.StartsWith("system_", StringComparison.Ordinal)) + return null; + + var sessionId = principal.FindFirstValue(SessionClaimTypes.SessionId); + return string.IsNullOrWhiteSpace(sessionId) ? null : sessionId; + } private async Task IsValidAsync(HubCallerContext context) { diff --git a/Web/Resgrid.Web.Eventing/Program.cs b/Web/Resgrid.Web.Eventing/Program.cs index 4ede644f0..701dff8db 100644 --- a/Web/Resgrid.Web.Eventing/Program.cs +++ b/Web/Resgrid.Web.Eventing/Program.cs @@ -93,6 +93,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); }); } } diff --git a/Web/Resgrid.Web.Eventing/Services/SessionConnectionSweepService.cs b/Web/Resgrid.Web.Eventing/Services/SessionConnectionSweepService.cs new file mode 100644 index 000000000..beb179986 --- /dev/null +++ b/Web/Resgrid.Web.Eventing/Services/SessionConnectionSweepService.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Resgrid.Config; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Web.Eventing.Services +{ + /// + /// Closes this host's SignalR connections whose session ended, locked or passed its idle deadline, every + /// SessionSecurityConfig.ConnectionSweepIntervalSeconds (passkey workbook section 12, slice 16). Invocations are + /// validated as they arrive; this stops a connection that only listens from receiving broadcasts after its session ends. + /// + public sealed class SessionConnectionSweepService : BackgroundService + { + private readonly SessionConnectionRegistry _connections; + private readonly IServiceScopeFactory _scopes; + + public SessionConnectionSweepService(SessionConnectionRegistry connections, IServiceScopeFactory scopes) + { + _connections = connections; + _scopes = scopes; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (SessionSecurityConfig.ConnectionSweepIntervalSeconds <= 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(5, SessionSecurityConfig.ConnectionSweepIntervalSeconds))); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + using var scope = _scopes.CreateScope(); + await _connections.SweepAsync(scope.ServiceProvider.GetRequiredService(), stoppingToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The next tick tries again; invocations are still validated one by one meanwhile. + Resgrid.Framework.Logging.LogException(ex, "The SignalR session sweep failed."); + } + } + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Services/SessionEventRelay.cs b/Web/Resgrid.Web.Eventing/Services/SessionEventRelay.cs new file mode 100644 index 000000000..8c3865702 --- /dev/null +++ b/Web/Resgrid.Web.Eventing/Services/SessionEventRelay.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Newtonsoft.Json; +using Resgrid.Model.Security; +using Resgrid.Services; + +namespace Resgrid.Web.Eventing.Services +{ + /// + /// Forwards a session event from the eventing topic to that session's SignalR group (passkey workbook section 7.4). A + /// malformed or unknown event is dropped: the topic never chooses what a client is told beyond the known shapes. + /// + public static class SessionEventRelay + { + public static async Task RelayAsync(IHubClients clients, SessionConnectionRegistry connections, string sessionId, string payload) + { + if (clients == null || string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(payload)) + return; + + SessionEventMessage message; + try + { + message = JsonConvert.DeserializeObject(payload); + } + catch (JsonException) + { + return; + } + + if (message == null || !SessionEvents.IsKnown(message.Name)) + return; + + // The session locked or ended: close its connections on this host, and tell the client nothing. + if (message.Name == SessionEvents.SessionClosed) + { + connections?.CloseSession(sessionId); + return; + } + + await clients.Group(SessionEvents.GroupFor(sessionId)).SendAsync(message.Name, + new { approvalRequestId = message.ApprovalRequestId, state = message.State }); + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index c0b9716b8..eefb576f9 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -142,6 +142,8 @@ public void ConfigureServices(IServiceCollection services) // Realtime location visibility: this instance's geolocation connections and their groups. services.AddSingleton(); + // Open connections by session, for the session sweep and session events (slice 16). + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -164,7 +166,10 @@ public void ConfigureServices(IServiceCollection services) config.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); config.Lockout.MaxFailedAccessAttempts = 5; config.Lockout.AllowedForNewUsers = true; - }).AddDefaultTokenProviders().AddClaimsPrincipalFactory>(); + }).AddDefaultTokenProviders() + // One-time TOTP steps (passkey plan section 7.5 rule 8): replaces Identity's authenticator provider. + .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider) + .AddClaimsPrincipalFactory>(); services.Configure(options => { diff --git a/Web/Resgrid.Web.Eventing/Worker.cs b/Web/Resgrid.Web.Eventing/Worker.cs index e3b60ae59..4e50c25da 100644 --- a/Web/Resgrid.Web.Eventing/Worker.cs +++ b/Web/Resgrid.Web.Eventing/Worker.cs @@ -27,10 +27,12 @@ public class Worker : BackgroundService private readonly IServiceProvider _serviceProvider; private readonly IRabbitInboundEventProvider _rabbitInboundEventProvider; private readonly GeolocationBroadcaster _geolocationBroadcaster; + private readonly Resgrid.Services.SessionConnectionRegistry _connections; public Worker(IServiceProvider serviceProvider, IHubContext eventingHub, IHubContext geolocationHub, IHubContext chatHub, - GeolocationBroadcaster geolocationBroadcaster) + GeolocationBroadcaster geolocationBroadcaster, Resgrid.Services.SessionConnectionRegistry connections) { + _connections = connections; _geolocationBroadcaster = geolocationBroadcaster; _serviceProvider = serviceProvider; _eventingHub = eventingHub; @@ -58,6 +60,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken = def _rabbitInboundEventProvider.RegisterForChatEvents(ChatEventReceived); _rabbitInboundEventProvider.RegisterForChecklistEvents((departmentId, id) => _eventingHub.Clients.Group(departmentId.ToString()).SendAsync("checklistUpdated", id)); + _rabbitInboundEventProvider.RegisterForSessionEvents(SessionEventReceived); await StartProviderAsync(); @@ -134,6 +137,12 @@ private async Task StartProviderAsync() // await _rabbitInboundEventProvider.Start(); //} + /// + /// An event for one session (passkey workbook section 7.4), sent only to that session's own connections. Only known + /// event names are forwarded, with only the request id and state. + /// + public Task SessionEventReceived(string sessionId, string payload) => SessionEventRelay.RelayAsync(_eventingHub.Clients, _connections, sessionId, payload); + public async Task PersonnelStatusUpdated(int departmentId, string id) { Console.WriteLine($"Processing RabbitMQ PersonnelStatusUpdated Event For {departmentId}"); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AccountSecurityController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AccountSecurityController.cs new file mode 100644 index 000000000..db6b49148 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/AccountSecurityController.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.AccountSecurity; +using Resgrid.Repositories.DataRepository.Stores; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// The account "Sign-in methods" view (passkey plan section 6.5): the signed-in user's own authenticator app and + /// passkeys, grouped by the app each passkey works in. Viewing needs only a signed-in session; every change goes + /// through a command that checks fresh MFA. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] + public class AccountSecurityController : V4AuthenticatedApiControllerbase + { + private readonly UserManager _userManager; + private readonly IPasskeyService _passkeys; + private readonly IUserMfaStateRepository _mfaState; + private readonly IUserStore _userStore; + private readonly IMfaEvidenceService _evidence; + private readonly IMfaPolicyService _policy; + private readonly IUserSessionService _sessions; + private readonly IExternalIdentityLinkService _identityLinks; + private readonly ISecurityNoticeService _notices; + private readonly ISystemAuditsService _audits; + private readonly IUserSessionsRepository _sessionRows; + private readonly IMfaActivityService _activity; + private readonly IDepartmentsService _departments; + private readonly IDepartmentSsoService _departmentSso; + + public AccountSecurityController(UserManager userManager, IPasskeyService passkeys, + IUserMfaStateRepository mfaState, IUserStore userStore, IMfaEvidenceService evidence, IMfaPolicyService policy, + IUserSessionService sessions, IExternalIdentityLinkService identityLinks, ISecurityNoticeService notices, ISystemAuditsService audits, + IUserSessionsRepository sessionRows, IMfaActivityService activity, IDepartmentsService departments, IDepartmentSsoService departmentSso) + { + _sessionRows = sessionRows; + _activity = activity; + _departments = departments; + _departmentSso = departmentSso; + _userManager = userManager; + _passkeys = passkeys; + _mfaState = mfaState; + _userStore = userStore; + _evidence = evidence; + _policy = policy; + _sessions = sessions; + _identityLinks = identityLinks; + _notices = notices; + _audits = audits; + } + + [HttpGet("Methods")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Methods(CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "session_revoked", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + + var totpEnrolled = await _userManager.GetTwoFactorEnabledAsync(user); + var codesLeft = totpEnrolled ? await _userManager.CountRecoveryCodesAsync(user) : 0; + var totpState = totpEnrolled ? await _mfaState.GetTotpStateAsync(user.Id, cancellationToken) : null; + var passkeys = await _passkeys.GetActiveForUserAsync(user.Id, cancellationToken); + var currentSession = HttpProtectedGrantContext.SessionOf(HttpContext); + var currentClient = currentSession?.ClientApplication; + var activity = await _activity.GetRecentAsync(user.Id, cancellationToken) ?? Array.Empty(); + var lastTotp = activity.FirstOrDefault(a => a.Successful && a.Method == (int)MfaEvidenceMethod.Totp); + + var result = new AccountMethodsResult + { + Data = new AccountMethodsResultData + { + CurrentClient = currentClient == null ? null : ApiPasskeys.ClientName((UserSessionClientApplication)currentClient.Value), + Totp = new TotpMethodData + { + Enrolled = totpEnrolled, + EnrolledOn = ApiPasskeys.Iso(totpState?.EnrolledOnUtc), + LastUsedOn = totpState == null || totpState.LastAcceptedTimeStep <= 0 ? null : ApiPasskeys.Iso(totpState.LastAcceptedOnUtc), + RecoveryCodesRemaining = codesLeft, + RecoveryCodeWarning = totpEnrolled && codesLeft <= TwoFactorConfig.RecoveryCodeWarningThreshold, + SetUpOnSharedInstallation = totpState?.EnrolledInSharedMode == true, + EnrolledClient = totpState?.EnrolledClientApplication is int enrolledClient + ? ApiPasskeys.ClientName((UserSessionClientApplication)enrolledClient) + : null, + EnrolledInstallation = totpState?.EnrolledInstallation, + LastUsedClient = lastTotp == null ? null : ApiPasskeys.ClientName((UserSessionClientApplication)lastTotp.ClientApplication), + LastUsedInstallation = lastTotp?.InstallationLabel, + CanTurnOff = totpEnrolled && passkeys.Count == 0 + }, + PasskeyGroups = ApiPasskeys.PasskeyClients.Select(client => new PasskeyClientGroupData + { + Client = ApiPasskeys.ClientName(client), + RegistrationAvailable = _passkeys.IsRegistrationAvailable(client), + Passkeys = passkeys.Where(p => p.ClientApplication == (int)client).Select(PasskeysController.ToData).ToList() + }).ToList(), + ApprovalInstallations = await ApprovalInstallationsAsync(user, passkeys, activity, currentSession?.SessionId), + LinkedIdentities = await LinkedIdentitiesAsync(user.Id, activity, cancellationToken), + RecentActivity = activity.Select(a => ToData(a, currentSession?.SessionId)).ToList() + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// "This wasn't me" (plan section 6.5): marks a verification as not the account holder's, ends the session it opened or + /// served (never the one reporting it) and sends a security notice. Its effect is ending a session, which any session of + /// the account may already do, so it needs no fresh MFA: a user whose factor was taken can still report. + /// + [HttpPost("ReportActivity")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReportActivity([FromBody] ReportActivityInput input, CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var user = await _userManager.FindByIdAsync(UserId); + if (session == null || user == null) + return Problem(type: "session_required", title: "Sign in again to continue.", statusCode: StatusCodes.Status409Conflict); + if (string.IsNullOrWhiteSpace(input?.ActivityId)) + return Problem(type: "invalid_request", title: "Choose the activity to report.", statusCode: StatusCodes.Status400BadRequest); + + var report = await _activity.ReportAsync(user.Id, input.ActivityId.Trim(), session.SessionId, + new SharedSessionRequestInfo + { + UserName = user.UserName, IpAddress = IpAddressHelper.GetRequestIP(Request, true), CorrelationId = HttpContext.TraceIdentifier + }, cancellationToken); + return report.Outcome switch + { + MfaActivityReportOutcome.NotFound => Problem(type: "activity_not_found", title: "That activity is not on this account or is too old to report.", + statusCode: StatusCodes.Status404NotFound), + MfaActivityReportOutcome.AlreadyReported => Problem(type: "activity_already_reported", title: "That activity was already reported.", + statusCode: StatusCodes.Status409Conflict), + _ => Wrap(new ReportActivityResult + { + Data = new ReportActivityResultData + { + SessionEnded = report.SessionEnded, + NextSteps = new List { "change_password", "review_methods" } + } + }) + }; + } + + /// + /// The user's Responder installations that can receive approval requests (plan section 6.5): active, personal (never + /// shared) Responder sessions. Push delivery is per account, not per installation, so no push state is shown. + /// + private async Task> ApprovalInstallationsAsync(Model.Identity.IdentityUser user, IReadOnlyList passkeys, + IReadOnlyList activity, string currentSessionId) + { + var now = DateTime.UtcNow; + var canApprove = passkeys.Any(p => Resgrid.Services.ApprovalApprovers.IsApprovingPasskey(p, user.Id)); + return (await _sessionRows.GetActiveByUserAsync(user.Id, now) ?? Array.Empty()) + .Where(s => s.ClientApplication == (int)UserSessionClientApplication.Responder && !s.SharedMode && s.State == (int)UserSessionState.Active && + s.RevokedOn == null && s.ExpiresOn > now && s.AuthenticationGeneration == user.AuthenticationGeneration) + .OrderByDescending(s => s.LastActiveOn) + .Select(s => + { + var decision = activity.FirstOrDefault(a => a.Method == (int)MfaEvidenceMethod.PasskeyApproval && + string.Equals(a.ApproverSessionId, s.UserSessionId, StringComparison.Ordinal)); + return new ApprovalInstallationData + { + InstallationId = s.UserSessionId, + Label = s.DeviceName, + Platform = s.OperatingSystem, + IsCurrent = string.Equals(s.UserSessionId, currentSessionId, StringComparison.Ordinal), + ApprovalsOn = canApprove && s.ApprovalsDisabledOnUtc == null, + StoppedOn = ApiPasskeys.Iso(s.ApprovalsDisabledOnUtc), + LastDecisionOn = ApiPasskeys.Iso(decision?.OccurredOnUtc), + LastDecision = decision == null ? null : decision.Successful ? "approved" : "denied" + }; + }) + .ToList(); + } + + /// The user's department identity-provider links; informational, managed by the department and SCIM. + private async Task> LinkedIdentitiesAsync(string userId, IReadOnlyList activity, CancellationToken cancellationToken) + { + var result = new List(); + foreach (var link in await _identityLinks.GetActiveLinksAsync(userId, cancellationToken) ?? Array.Empty()) + { + var department = await _departments.GetDepartmentByIdAsync(link.DepartmentId, false); + var lastStepUp = activity.FirstOrDefault(a => a.Successful && a.Method == (int)MfaEvidenceMethod.Federated && a.DepartmentId == link.DepartmentId); + result.Add(new LinkedIdentityData + { + DepartmentId = link.DepartmentId, + DepartmentName = department?.Name, + ProviderType = (SsoProviderType)link.ProviderType switch + { + SsoProviderType.Saml2 => "saml2", + SsoProviderType.Oidc => "oidc", + _ => null + }, + LinkedOn = ApiPasskeys.Iso(link.LinkedOn), + AcceptsProviderStepUp = await _policy.IsMethodAcceptedAsync(link.DepartmentId, MfaMethodScope.Login, MfaEvidenceMethod.Federated, cancellationToken) && + await _departmentSso.IsFederatedMfaAvailableAsync(link.DepartmentId, userId, cancellationToken), + LastProviderStepUpOn = ApiPasskeys.Iso(lastStepUp?.OccurredOnUtc) + }); + } + + return result; + } + + private static MfaActivityData ToData(MfaActivity activity, string currentSessionId) => new() + { + ActivityId = activity.MfaActivityId, + OccurredOn = ApiPasskeys.Iso(activity.OccurredOnUtc), + Method = (MfaEvidenceMethod)activity.Method == MfaEvidenceMethod.RecoveryCode ? "recovery_code" : MfaMethodNames.From((MfaEvidenceMethod)activity.Method), + Purpose = (MfaEvidencePurpose)activity.Purpose switch + { + MfaEvidencePurpose.Login => "login", + MfaEvidencePurpose.Reauthentication => "reauthentication", + MfaEvidencePurpose.StepUp => "step_up", + MfaEvidencePurpose.AdpStepUp => "adp_step_up", + MfaEvidencePurpose.SharedUnlock => "shared_unlock", + _ => null + }, + Successful = activity.Successful, + Client = ApiPasskeys.ClientName((UserSessionClientApplication)activity.ClientApplication), + Installation = activity.InstallationLabel, + SharedInstallation = activity.SharedMode, + IsCurrentSession = activity.SessionId != null && string.Equals(activity.SessionId, currentSessionId, StringComparison.Ordinal), + ReportedOn = ApiPasskeys.Iso(activity.ReportedOnUtc) + }; + + // ── Reauthentication (plan section 6.2) ─────────────────────────────────────── + + /// + /// Confirms the password for this session: fresh first-factor evidence for operations that need it (adding a passkey, + /// replacing the authenticator). Failures count toward the account lockout. SSO accounts reauthenticate through + /// Sso/Begin with purpose reauth. + /// + [HttpPost("Reauthenticate")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Reauthenticate([FromBody] ReauthenticateInput input, CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var sessionKey = ApiStepUpEvidence.SessionKey(HttpContext); + var user = await _userManager.FindByIdAsync(UserId); + if (session == null || sessionKey == null || user == null) + return Problem(type: "session_required", title: "Sign in again to continue.", statusCode: StatusCodes.Status409Conflict); + if (string.IsNullOrWhiteSpace(input?.Password)) + return Problem(type: "invalid_request", title: "Enter your password.", statusCode: StatusCodes.Status400BadRequest); + if (!await _userManager.HasPasswordAsync(user) || !await _identityLinks.IsLocalLoginAllowedAsync(user.Id, DepartmentId, cancellationToken)) + return Problem(type: "sso_reauthentication_required", title: "Confirm who you are with your organization's sign-in instead.", + statusCode: StatusCodes.Status409Conflict); + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + if (!await _userManager.CheckPasswordAsync(user, input.Password)) + { + await _userManager.AccessFailedAsync(user); + await AuditAsync(user, SystemAuditTypes.AccountReauthenticated, false, "Password reauthentication failed.", cancellationToken); + return Problem(type: "invalid_grant", title: "The password is incorrect.", statusCode: StatusCodes.Status401Unauthorized); + } + + await _userManager.ResetAccessFailedCountAsync(user); + var now = DateTime.UtcNow; + await _evidence.RecordAsync(user.Id, sessionKey, (UserSessionClientApplication)session.ClientApplication, MfaEvidenceKind.FirstFactor, + MfaEvidenceMethod.Password, MfaEvidencePurpose.Reauthentication, now, session.AuthenticationGeneration, DepartmentId, + cancellationToken: cancellationToken); + await AuditAsync(user, SystemAuditTypes.AccountReauthenticated, true, "Password confirmed for this session.", cancellationToken); + return Wrap(new ReauthenticateResult { Data = new ReauthenticateResultData { VerifiedAt = now.ToString("O") } }); + } + + // ── Replacing the authenticator (plan sections 6.2 and 7.5 rule 7) ──────────────── + + /// + /// Stages a new authenticator key. Needs the password (or SSO) confirmed within five minutes and the current + /// authenticator or a passkey for this app verified within five minutes (plan section 7.6 row 14). + /// + [HttpPost("ReplaceTotpOptions")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReplaceTotpOptions(CancellationToken cancellationToken) + { + var (user, refusal) = await ReplacementAuthorityAsync(TwoFactorConfig.FirstFactorReauthWindowMinutes, cancellationToken); + if (refusal != null) + return refusal; + + var (sharedKey, uri) = await AuthenticatorSetup.StageAsync(_userManager, user); + return Wrap(new ReplaceTotpOptionsResult + { + Data = new ReplaceTotpOptionsResultData + { + SharedKey = sharedKey, + AuthenticatorUri = uri, + ExpiresIn = (int)TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.StagedAuthenticatorLifetimeMinutes)).TotalSeconds + } + }); + } + + /// + /// Makes the staged key the authenticator once its code verifies. Everything the old one authorized ends: every + /// session (this one too), all evidence, and the old recovery codes. The new codes are returned once. + /// + [HttpPost("ReplaceTotp")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReplaceTotp([FromBody] ReplaceTotpInput input, CancellationToken cancellationToken) + { + var (user, refusal) = await ReplacementAuthorityAsync(TwoFactorConfig.FirstFactorOperationWindowMinutes, cancellationToken); + if (refusal != null) + return refusal; + + var stagedKey = await AuthenticatorSetup.GetStagedKeyAsync(_userManager, user); + if (stagedKey == null) + return Problem(type: "setup_expired", title: "Start replacing the authenticator again.", statusCode: StatusCodes.Status400BadRequest); + if (!await AuthenticatorSetup.VerifyStagedCodeAsync(_mfaState, user, stagedKey, input?.Code, cancellationToken)) + { + await _userManager.AccessFailedAsync(user); + return Problem(type: "invalid_totp", title: "The code from the new authenticator is invalid or has expired.", + statusCode: StatusCodes.Status401Unauthorized); + } + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var shared = session?.SharedMode == true; + var sessionRow = session == null ? null : await _sessionRows.GetByIdAsync(session.SessionId); + await AuthenticatorSetup.PromoteAsync(_userManager, _userStore, _mfaState, user, stagedKey, + new TotpEnrollmentContext(shared, session?.ClientApplication, sessionRow?.DeviceName), cancellationToken); + var codes = await AuthenticatorSetup.RetireOldAuthorityAsync(_userManager, _sessions, _evidence, user, cancellationToken); + await AuditAsync(user, SystemAuditTypes.TwoFactorAuthenticatorReplaced, true, "Authenticator replaced via the API; all sessions revoked.", + cancellationToken); + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = user.Id, Kind = SecurityNoticeKind.TotpReplaced, ClientApplication = (UserSessionClientApplication?)session?.ClientApplication + }, cancellationToken); + // Its setup key was on a shared screen (plan section 6.5): say so, so the user can replace it from a personal device. + if (shared) + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = user.Id, Kind = SecurityNoticeKind.SharedInstallationFactor, ClientApplication = (UserSessionClientApplication?)session.ClientApplication + }, cancellationToken); + + return Wrap(new ReplaceTotpResult { Data = new ReplaceTotpResultData { RecoveryCodes = codes.ToList(), SignInAgain = true } }); + } + + /// + /// The authority to replace the authenticator: this session's first factor within , + /// and TOTP or a passkey for this app within five minutes. Never approval or provider step-up (plan section 7.6 row 14). + /// + private async Task<(Model.Identity.IdentityUser User, ObjectResult Refusal)> ReplacementAuthorityAsync(int firstFactorMinutes, + CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var sessionKey = ApiStepUpEvidence.SessionKey(HttpContext); + var user = await _userManager.FindByIdAsync(UserId); + if (session == null || sessionKey == null || user == null) + return (null, Problem(type: "session_required", title: "Sign in again to continue.", statusCode: StatusCodes.Status409Conflict)); + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return (null, Problem(type: "mfa_enrollment_required", title: "There is no authenticator to replace.", statusCode: StatusCodes.Status409Conflict)); + if (await _userManager.IsLockedOutAsync(user)) + return (null, Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests)); + + if (!await _evidence.HasFreshFirstFactorAsync(user.Id, sessionKey, session.AuthenticationGeneration, + TimeSpan.FromMinutes(Math.Max(1, firstFactorMinutes)), DateTime.UtcNow, cancellationToken)) + return (null, Problem(type: "reauthentication_required", title: "Confirm your password (or sign in with SSO) again first.", + statusCode: StatusCodes.Status409Conflict)); + + if (!await ApiStepUpEvidence.HasRecentSecondFactorAsync(_evidence, _policy, user.Id, HttpContext, DepartmentId, MfaMethodScope.Account, + MfaStepUpOperations.WindowFor(MfaStepUpOperations.AccountSecurity), cancellationToken)) + return (null, Problem(type: "step_up_required", + title: "Verify with your current authenticator or a passkey for this app first (Mfa/VerifyStepUp, operation account_security).", + statusCode: StatusCodes.Status403Forbidden)); + + return (user, null); + } + + private T Wrap(T result) where T : Models.v4.StandardApiResponseV4Base + { + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private Task AuditAsync(Model.Identity.IdentityUser user, SystemAuditTypes type, bool successful, string data, CancellationToken cancellationToken) => + _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)type, + UserId = user.Id, + Username = user.UserName, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = data + }, cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/AuthenticationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/AuthenticationController.cs new file mode 100644 index 000000000..8126be70c --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/AuthenticationController.cs @@ -0,0 +1,452 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Authentication; +using Resgrid.Web.Services.Models.v4.Passkeys; +using Resgrid.Repositories.DataRepository.Stores; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Second-factor completion for a login MFA transaction (passkey plan sections 7.2 and 7.5 rule 3; workbook section + /// 7.1). The transaction secret from the password grant is the only authority here: no password or IdP token is + /// resent, and nothing here issues a token. TOTP, a passkey bound to the signing-in app, or a recovery code completes it + /// once; failures of any method share the transaction's attempt limit and the account lockout (section 7.5 rule 6). + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [AllowAnonymous] + public class AuthenticationController : ControllerBase + { + private readonly UserManager _userManager; + private readonly IMfaLoginTransactionService _transactions; + private readonly IPasskeyService _passkeys; + private readonly ISystemAuditsService _systemAudits; + private readonly ISsoBrokerService _ssoBroker; + private readonly IDepartmentSsoService _departmentSso; + private readonly IMfaApprovalService _approvals; + private readonly IUserStore _userStore; + private readonly IUserMfaStateRepository _mfaState; + private readonly ISecurityNoticeService _notices; + + private readonly IMfaActivityService _activity; + + public AuthenticationController(UserManager userManager, IMfaLoginTransactionService transactions, + IPasskeyService passkeys, ISystemAuditsService systemAudits, ISsoBrokerService ssoBroker, IDepartmentSsoService departmentSso, + IMfaApprovalService approvals, IUserStore userStore, IUserMfaStateRepository mfaState, + ISecurityNoticeService notices, IMfaActivityService activity) + { + _activity = activity; + _userStore = userStore; + _mfaState = mfaState; + _notices = notices; + _approvals = approvals; + _ssoBroker = ssoBroker; + _departmentSso = departmentSso; + _userManager = userManager; + _transactions = transactions; + _passkeys = passkeys; + _systemAudits = systemAudits; + } + + /// Completes the sign-in with the current authenticator code. + [HttpPost("CompleteTotp")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteTotp([FromBody] CompleteLoginCodeInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.Totp, cancellationToken); + if (refusal != null) + return refusal; + if (string.IsNullOrWhiteSpace(input.Code)) + return Refuse("invalid_totp", "A verification code is required.", StatusCodes.Status400BadRequest); + + // One-time: ResgridAuthenticatorTokenProvider accepts each time step once per user, on every surface. + if (!await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, + input.Code.Replace(" ", string.Empty).Replace("-", string.Empty))) + return await FailedAsync(transaction, user, MfaEvidenceMethod.Totp, "invalid_totp", + "The verification code is invalid or has expired.", StatusCodes.Status401Unauthorized, cancellationToken); + + return await CompleteAsync(transaction, user, MfaEvidenceMethod.Totp, null, DateTime.UtcNow, cancellationToken); + } + + /// Assertion options for the user's passkeys bound to the signing-in app, bound to this transaction. + [HttpPost("PasskeyOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PasskeyOptions([FromBody] LoginTransactionInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.Passkey, cancellationToken); + if (refusal != null) + return refusal; + + var start = await _passkeys.BeginAssertionAsync(PasskeyCaller.ForLoginTransaction(transaction, user.UserName, SystemAuditSystems.Api, + IpAddressHelper.GetRequestIP(Request, true)), AuthenticationChallengePurpose.LoginSecondFactor, cancellationToken); + if (!start.Succeeded) + return Refuse(PasskeyOutcomes.ErrorCode(start.Outcome), ApiPasskeys.TitleFor(start.Outcome), ApiPasskeys.StatusFor(start.Outcome)); + + var result = new PasskeyCeremonyResult + { + Data = new PasskeyCeremonyResultData { RequestId = start.RequestId, Options = ApiPasskeys.Options(start.OptionsJson) }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Completes the sign-in with a passkey assertion for the request from . + [HttpPost("CompletePasskey")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompletePasskey([FromBody] CompleteLoginPasskeyInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.Passkey, cancellationToken); + if (refusal != null) + return refusal; + + var assertion = await _passkeys.CompleteAssertionAsync(PasskeyCaller.ForLoginTransaction(transaction, user.UserName, SystemAuditSystems.Api, + IpAddressHelper.GetRequestIP(Request, true)), AuthenticationChallengePurpose.LoginSecondFactor, input.RequestId, + ApiPasskeys.CredentialJson(input.Credential), cancellationToken); + if (!assertion.Succeeded) + { + // A signature that did not verify is a failed second factor like a wrong code; an expired or reused request is not. + if (assertion.Outcome is PasskeyOutcome.VerificationFailed or PasskeyOutcome.NotRegisteredForClient) + return await FailedAsync(transaction, user, MfaEvidenceMethod.Passkey, PasskeyOutcomes.ErrorCode(assertion.Outcome), + ApiPasskeys.TitleFor(assertion.Outcome), ApiPasskeys.StatusFor(assertion.Outcome), cancellationToken); + + return Refuse(PasskeyOutcomes.ErrorCode(assertion.Outcome), ApiPasskeys.TitleFor(assertion.Outcome), ApiPasskeys.StatusFor(assertion.Outcome)); + } + + return await CompleteAsync(transaction, user, MfaEvidenceMethod.Passkey, UserPasskey.FactorReferenceFor(assertion.Passkey.UserPasskeyId), + assertion.VerifiedOnUtc, cancellationToken); + } + + /// + /// Completes the sign-in with provider step-up (plan sections 7.6 row 3 and 7.8): the brokered round trip begun + /// through Sso/Begin with purpose step_up and this transaction, redeemed once here with its one-time code + /// and PKCE verifier. The provider must have signed in this account after the step-up began, with a value the + /// department's tested mapping counts as MFA; the evidence carries the provider's authentication time. + /// + [HttpPost("CompleteFederated")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteFederated([FromBody] CompleteLoginFederatedInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.Federated, cancellationToken); + if (refusal != null) + return refusal; + + var redeemed = await _ssoBroker.RedeemAsync(input.SsoTransactionId, input.SsoCode, input.CodeVerifier, + ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), cancellationToken, SsoTransactionPurpose.StepUp); + if (!redeemed.Succeeded) + return Refuse(SsoBrokerOutcomes.ErrorCode(redeemed.Outcome) ?? "sso_failed", "The provider step-up could not be completed. Start it again.", + redeemed.Outcome == SsoBrokerOutcome.ServiceUnavailable ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status400BadRequest); + + // The step-up must be this sign-in's: its login transaction, account and generation, under the unchanged mapping. + var step = redeemed.Transaction; + var config = transaction.DepartmentId is int departmentId + ? await _departmentSso.GetTestedFederatedMfaConfigAsync(departmentId, cancellationToken) + : null; + if (!FederatedMfaMapping.Satisfies(step, config) || step.DepartmentId != transaction.DepartmentId || + !string.Equals(step.LoginTransactionId, transaction.MfaLoginTransactionId, StringComparison.Ordinal) || + !string.Equals(step.Operation, SsoLoginTransaction.LoginOperation, StringComparison.Ordinal) || + !string.Equals(step.ExpectedUserId, transaction.UserId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(step.UserId, transaction.UserId, StringComparison.OrdinalIgnoreCase) || + step.AuthenticationGeneration != transaction.AuthenticationGeneration) + return await FailedAsync(transaction, user, MfaEvidenceMethod.Federated, "federated_mfa_not_satisfied", + "The provider step-up does not belong to this sign-in. Start it again.", StatusCodes.Status401Unauthorized, cancellationToken); + + return await CompleteAsync(transaction, user, MfaEvidenceMethod.Federated, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion), + step.AuthenticatedOnUtc ?? DateTime.UtcNow, cancellationToken); + } + + /// + /// Completes the sign-in with a Responder approval (plan section 7.9 step 6): the request made for this transaction + /// through MfaApproval/Request and approved in the user's Responder. It is used once, while the approving + /// passkey and Responder session still count; the evidence names them and carries the approval time. + /// + [HttpPost("CompleteApproval")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteApproval([FromBody] CompleteLoginApprovalInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.PasskeyApproval, cancellationToken); + if (refusal != null) + return refusal; + + var consumed = await _approvals.ConsumeAsync(input.ApprovalRequestId, MfaApprovalRequesterKind.LoginTransaction, transaction.MfaLoginTransactionId, + transaction.UserId, transaction.AuthenticationGeneration, cancellationToken); + if (!consumed.Succeeded) + return Refuse(MfaApprovalOutcomes.ErrorCode(consumed.Outcome) ?? "approval_unavailable", consumed.Outcome switch + { + MfaApprovalOutcome.Pending => "The request has not been approved yet.", + MfaApprovalOutcome.Denied => "The request was denied in Responder. Use another verification method.", + _ => "The approval is no longer valid. Request it again." + }, consumed.Outcome switch + { + MfaApprovalOutcome.Pending => StatusCodes.Status409Conflict, + MfaApprovalOutcome.Denied => StatusCodes.Status403Forbidden, + MfaApprovalOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + + var approval = consumed.Request; + return await CompleteAsync(transaction, user, MfaEvidenceMethod.PasskeyApproval, + MfaApprovalRequest.FactorReferenceFor(approval.ApproverPasskeyId, approval.ApproverSessionId), approval.DecidedOnUtc ?? DateTime.UtcNow, + cancellationToken); + } + + /// + /// Completes the sign-in with a one-time recovery code. The session is recovery-classified: it never satisfies a + /// later MFA check or ADP, and the user should replace the lost factor (plan sections 6.1 item 10 and 6.3). + /// + [HttpPost("CompleteRecoveryCode")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteRecoveryCode([FromBody] CompleteLoginCodeInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenAsync(input, MfaEvidenceMethod.RecoveryCode, cancellationToken); + if (refusal != null) + return refusal; + if (string.IsNullOrWhiteSpace(input.Code)) + return Refuse("invalid_recovery_code", "A recovery code is required.", StatusCodes.Status400BadRequest); + + var redeemed = await _userManager.RedeemTwoFactorRecoveryCodeAsync(user, input.Code.Trim()); + if (!redeemed.Succeeded) + return await FailedAsync(transaction, user, MfaEvidenceMethod.RecoveryCode, "invalid_recovery_code", + "The recovery code is invalid or has already been used.", StatusCodes.Status401Unauthorized, cancellationToken); + + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = user.Id, Kind = SecurityNoticeKind.RecoveryCodeUsed, ClientApplication = (UserSessionClientApplication)transaction.ClientApplication + }, cancellationToken); + return await CompleteAsync(transaction, user, MfaEvidenceMethod.RecoveryCode, null, DateTime.UtcNow, cancellationToken); + } + + // ── Setup transaction (plan section 6.2) ───────────────────────────────────────── + + /// + /// For a sign-in that must have MFA the account does not have yet (mfa_setup_transaction): stages a new + /// authenticator key. The transaction permits only this setup; it grants nothing until a code from the new key verifies. + /// + [HttpPost("TotpSetupOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TotpSetupOptions([FromBody] LoginTransactionInput input, CancellationToken cancellationToken) + { + var (_, user, refusal) = await OpenForSetupAsync(input, cancellationToken); + if (refusal != null) + return refusal; + + var (sharedKey, uri) = await AuthenticatorSetup.StageAsync(_userManager, user); + var result = new TotpSetupResult + { + Data = new TotpSetupResultData + { + SharedKey = sharedKey, + AuthenticatorUri = uri, + ExpiresIn = (int)TimeSpan.FromMinutes(Math.Max(1, Config.TwoFactorConfig.StagedAuthenticatorLifetimeMinutes)).TotalSeconds + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Turns the new authenticator on once its code verifies, and completes the sign-in with it: the completion code + /// redeems at the token endpoint like any other, and the recovery codes are returned once. + /// + [HttpPost("CompleteTotpSetup")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteTotpSetup([FromBody] CompleteLoginCodeInput input, CancellationToken cancellationToken) + { + var (transaction, user, refusal) = await OpenForSetupAsync(input, cancellationToken); + if (refusal != null) + return refusal; + + var stagedKey = await AuthenticatorSetup.GetStagedKeyAsync(_userManager, user); + if (stagedKey == null) + return Refuse("setup_expired", "Start the authenticator setup again.", StatusCodes.Status400BadRequest); + if (!await AuthenticatorSetup.VerifyStagedCodeAsync(_mfaState, user, stagedKey, input.Code, cancellationToken)) + return await FailedAsync(transaction, user, MfaEvidenceMethod.Totp, "invalid_totp", + "The verification code is invalid or has expired.", StatusCodes.Status401Unauthorized, cancellationToken); + + // Set up on a shared installation when the installation says so or the department makes this app's sessions shared + // (plan section 6.5); the flag only ever adds a warning. + var client = (UserSessionClientApplication)transaction.ClientApplication; + var shared = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]) || + (transaction.DepartmentId is int departmentId && + SharedSessionRules.IsRequiredFor(await _departmentSso.GetSecurityPolicyForDepartmentAsync(departmentId, cancellationToken), client)); + await AuthenticatorSetup.PromoteAsync(_userManager, _userStore, _mfaState, user, stagedKey, + new TotpEnrollmentContext(shared, (int)client, Request.Headers["X-Resgrid-Device-Name"].ToString()), cancellationToken); + var codes = await AuthenticatorSetup.NewRecoveryCodesAsync(_userManager, user); + await _systemAudits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.TwoFactorEnabled, + UserId = user.Id, + Username = user.UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = "Authenticator set up during sign-in (setup transaction)." + }, cancellationToken); + await _notices.QueueAsync(new SecurityNoticeRequest + { + UserId = user.Id, Kind = SecurityNoticeKind.TotpEnabled, ClientApplication = client + }, cancellationToken); + if (shared) + await _notices.QueueAsync(new SecurityNoticeRequest { UserId = user.Id, Kind = SecurityNoticeKind.SharedInstallationFactor, ClientApplication = client }, + cancellationToken); + + var completed = await CompleteAsync(transaction, user, MfaEvidenceMethod.Totp, null, DateTime.UtcNow, cancellationToken); + if (completed.Value?.Data != null) + completed.Value.Data.RecoveryCodes = codes.ToList(); + return completed; + } + + /// A pending transaction whose account has no authenticator yet: the only thing it may do is set one up. + private async Task<(MfaLoginTransaction Transaction, Model.Identity.IdentityUser User, ObjectResult Refusal)> OpenForSetupAsync( + LoginTransactionInput input, CancellationToken cancellationToken) + { + var opened = await _transactions.OpenAsync(input?.Transaction, ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + cancellationToken); + if (!opened.IsUsable) + return (null, null, TransactionRefusal(opened.Outcome)); + + var user = await _userManager.FindByIdAsync(opened.Transaction.UserId); + if (user == null) + return (null, null, TransactionRefusal(MfaLoginTransactionOutcome.SessionRevoked)); + if (await _userManager.IsLockedOutAsync(user)) + return (null, null, Refuse("too_many_attempts", "Too many failed attempts. Wait a few minutes and sign in again.", + StatusCodes.Status429TooManyRequests)); + if (await _userManager.GetTwoFactorEnabledAsync(user)) + return (null, null, Refuse("mfa_method_not_allowed", "This account already has an authenticator. Use it to sign in.", + StatusCodes.Status400BadRequest)); + + return (opened.Transaction, user, null); + } + + /// + /// The pending transaction for this client and its user, or the refusal: an unusable transaction, a locked account, + /// a method the department does not accept, or TOTP-based methods (TOTP and recovery codes) for an account without TOTP. + /// + private async Task<(MfaLoginTransaction Transaction, Model.Identity.IdentityUser User, ObjectResult Refusal)> OpenAsync( + LoginTransactionInput input, MfaEvidenceMethod method, CancellationToken cancellationToken) + { + var opened = await _transactions.OpenAsync(input?.Transaction, ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + cancellationToken); + if (!opened.IsUsable) + return (null, null, TransactionRefusal(opened.Outcome)); + + var transaction = opened.Transaction; + var user = await _userManager.FindByIdAsync(transaction.UserId); + if (user == null) + return (null, null, TransactionRefusal(MfaLoginTransactionOutcome.SessionRevoked)); + + if (await _userManager.IsLockedOutAsync(user)) + return (null, null, Refuse("too_many_attempts", "Too many failed attempts. Wait a few minutes and sign in again.", + StatusCodes.Status429TooManyRequests)); + + if (((method is MfaEvidenceMethod.Totp or MfaEvidenceMethod.RecoveryCode) && !await _userManager.GetTwoFactorEnabledAsync(user)) || + !await _transactions.IsMethodAcceptedAsync(transaction, method, cancellationToken)) + return (null, null, Refuse("mfa_method_not_allowed", "That verification method is not available for this sign-in.", + StatusCodes.Status400BadRequest)); + + return (transaction, user, null); + } + + private async Task FailedAsync(MfaLoginTransaction transaction, Model.Identity.IdentityUser user, MfaEvidenceMethod method, + string type, string title, int status, CancellationToken cancellationToken) + { + await _userManager.AccessFailedAsync(user); + await _transactions.RecordFailedAttemptAsync(transaction, cancellationToken); + await AuditAsync(user, false, method, cancellationToken); + await _activity.RecordAsync(new MfaActivityEntry + { + UserId = user.Id, Method = method, Purpose = MfaEvidencePurpose.Login, Successful = false, + ClientApplication = (UserSessionClientApplication)transaction.ClientApplication, DepartmentId = transaction.DepartmentId, + InstallationLabel = Request.Headers["X-Resgrid-Device-Name"].ToString() + }, cancellationToken); + return Refuse(type, title, status); + } + + private async Task> CompleteAsync(MfaLoginTransaction transaction, Model.Identity.IdentityUser user, + MfaEvidenceMethod method, string factorReference, DateTime verifiedOnUtc, CancellationToken cancellationToken) + { + await _userManager.ResetAccessFailedCountAsync(user); + + MfaLoginCompletion completion; + try + { + completion = await _transactions.CompleteAsync(transaction, method, factorReference, verifiedOnUtc, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "A verified login second factor could not be recorded; the sign-in was refused."); + return TransactionRefusal(MfaLoginTransactionOutcome.Unavailable); + } + + if (!completion.Succeeded) + return TransactionRefusal(completion.Outcome); + + await AuditAsync(user, true, method, cancellationToken); + var result = new LoginCompletionResult + { + Data = new LoginCompletionResultData + { + CompletionCode = completion.CompletionCode, + ExpiresIn = completion.ExpiresInSeconds, + Recovery = method == MfaEvidenceMethod.RecoveryCode + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ObjectResult TransactionRefusal(MfaLoginTransactionOutcome outcome) => Refuse(MfaLoginTransactions.ErrorCode(outcome) ?? "mfa_transaction_invalid", + outcome switch + { + MfaLoginTransactionOutcome.Expired => "The sign-in took too long. Sign in again.", + MfaLoginTransactionOutcome.TooManyAttempts => "Too many failed attempts. Sign in again.", + MfaLoginTransactionOutcome.PolicyChanged => "Your department's sign-in policy changed. Sign in again.", + MfaLoginTransactionOutcome.SessionRevoked => "Your account's sign-in state changed. Sign in again.", + MfaLoginTransactionOutcome.Unavailable => "Sign-in is temporarily unavailable. Try again.", + _ => "This sign-in is no longer valid. Sign in again." + }, + outcome switch + { + MfaLoginTransactionOutcome.TooManyAttempts => StatusCodes.Status429TooManyRequests, + MfaLoginTransactionOutcome.PolicyChanged => StatusCodes.Status409Conflict, + MfaLoginTransactionOutcome.SessionRevoked => StatusCodes.Status401Unauthorized, + MfaLoginTransactionOutcome.Unavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + + private ObjectResult Refuse(string type, string title, int status) => Problem(type: type, title: title, statusCode: status); + + private Task AuditAsync(Model.Identity.IdentityUser user, bool successful, MfaEvidenceMethod method, CancellationToken cancellationToken) => + _systemAudits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.Login, + UserId = user.Id, + Username = user.UserName, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = $"Login MFA transaction with {(method == MfaEvidenceMethod.RecoveryCode ? "a recovery code" : MfaMethodNames.From(method))}: " + + (successful ? "second factor verified; completion code issued." : "verification failed.") + }, cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs index 42378058f..3ee810e82 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatModerationController.cs @@ -64,8 +64,13 @@ public ChatModerationController( IAuthorizationService authorizationService, IEventAggregator eventAggregator, ICacheProvider cacheProvider, - UserManager userManager) + UserManager userManager, + IMfaEvidenceService mfaEvidence, + IMfaPolicyService mfaPolicy, + IDepartmentSsoService departmentSso) { + _mfaPolicy = mfaPolicy; + _departmentSso = departmentSso; _chatModerationService = chatModerationService; _chatChannelService = chatChannelService; _chatPermissionService = chatPermissionService; @@ -75,8 +80,13 @@ public ChatModerationController( _eventAggregator = eventAggregator; _cacheProvider = cacheProvider; _userManager = userManager; + _mfaEvidence = mfaEvidence; } + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaPolicyService _mfaPolicy; + private readonly IDepartmentSsoService _departmentSso; + private static string GetExportMfaProofCacheKey(string userId) => $"chat:export:mfa:{userId}"; #endregion Members and Constructors @@ -649,10 +659,24 @@ public async Task> VerifyExportMfa([FromBody] Ver // don't accumulate toward an account lockout. await _userManager.ResetAccessFailedCountAsync(user); - await _cacheProvider.SetStringAsync( - GetExportMfaProofCacheKey(user.Id), - DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), - TimeSpan.FromMinutes(ExportMfaWindowMinutes)); + // The proof is step-up evidence on this session (passkey plan section 7.6 row 8), so it cannot be used from + // another session or survive a password change. Mfa/VerifyStepUp records the same evidence. A token without a + // tracked session keeps the older per-user proof until it is re-issued. + var sessionKey = ApiStepUpEvidence.SessionKey(HttpContext); + if (sessionKey != null) + { + var client = HttpProtectedGrantContext.SessionOf(HttpContext)?.ClientApplication ?? (int)UserSessionClientApplication.Api; + await _mfaEvidence.RecordAsync(user.Id, sessionKey, (UserSessionClientApplication)client, Model.Security.MfaEvidenceKind.SecondFactor, + Model.Security.MfaEvidenceMethod.Totp, Model.Security.MfaEvidencePurpose.StepUp, DateTime.UtcNow, user.AuthenticationGeneration, + DepartmentId, cancellationToken: cancellationToken); + } + else + { + await _cacheProvider.SetStringAsync( + GetExportMfaProofCacheKey(user.Id), + DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), + TimeSpan.FromMinutes(ExportMfaWindowMinutes)); + } result.Success = true; result.Status = ResponseHelper.Success; @@ -664,14 +688,20 @@ await _cacheProvider.SetStringAsync( /// Resolves the current user and enforces the export MFA-enrollment precondition shared by the verify /// and gate paths. On success returns the user with a null error; otherwise returns a null user and /// the HTTP result to return: 401 when the principal can't be resolved, 403 when 2FA is not enrolled. + /// With , an SSO member whose department accepts its identity provider's MFA + /// for sign-in counts as enrolled without a Resgrid factor (passkey plan section 7.6 row 8, section 7.8): they verify + /// through Mfa/VerifyStepUp. The TOTP-only VerifyExportMfa still needs TOTP; passkeys and approval need it anyway. /// - private async Task<(Model.Identity.IdentityUser User, ActionResult Error)> GetMfaEnrolledUserOrErrorAsync() + private async Task<(Model.Identity.IdentityUser User, ActionResult Error)> GetMfaEnrolledUserOrErrorAsync(bool acceptProviderStepUp = false) { var user = await _userManager.GetUserAsync(User); if (user == null) return (null, Unauthorized()); - if (!await _userManager.GetTwoFactorEnabledAsync(user)) + if (!await _userManager.GetTwoFactorEnabledAsync(user) && + !(acceptProviderStepUp && + await _mfaPolicy.IsMethodAcceptedAsync(DepartmentId, Model.Security.MfaMethodScope.Login, Model.Security.MfaEvidenceMethod.Federated) && + await _departmentSso.IsFederatedMfaAvailableAsync(DepartmentId, user.Id))) return (null, StatusCode(StatusCodes.Status403Forbidden, new { error = "mfa_enrollment_required", error_description = "Two-Factor Authentication must be enabled to export chat transcripts." })); return (user, null); @@ -684,10 +714,22 @@ await _cacheProvider.SetStringAsync( /// private async Task> CheckRecentExportMfaAsync() { - var (user, mfaError) = await GetMfaEnrolledUserOrErrorAsync(); + var (user, mfaError) = await GetMfaEnrolledUserOrErrorAsync(acceptProviderStepUp: true); if (mfaError != null) return mfaError; + // Explicit step-up evidence on this session, never a sign-in and never another session's proof. + var sessionKey = ApiStepUpEvidence.SessionKey(HttpContext); + if (sessionKey != null) + { + var latest = await _mfaEvidence.GetLatestStepUpAsync(user.Id, sessionKey, user.AuthenticationGeneration); + if (Resgrid.Services.MfaEvidenceService.IsFresh(latest, TimeSpan.FromMinutes(ExportMfaWindowMinutes), DateTime.UtcNow) && + await _mfaPolicy.IsMethodAcceptedAsync(DepartmentId, Model.Security.MfaMethodScope.Login, (Model.Security.MfaEvidenceMethod)latest.Method)) + return null; + + return StatusCode(StatusCodes.Status401Unauthorized, new { error = "mfa_required", error_description = $"Recent Two-Factor verification is required. Call VerifyExportMfa (or Mfa/VerifyStepUp with operation chat_export) with your current code, then retry within {ExportMfaWindowMinutes} minutes." }); + } + var proof = await _cacheProvider.GetStringAsync(GetExportMfaProofCacheKey(user.Id)); if (!string.IsNullOrEmpty(proof) && DateTime.TryParse(proof, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var verifiedAt) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs index 44e415591..3b0911a2f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs @@ -66,7 +66,11 @@ public ConnectController( IEncryptionService encryptionService, ICacheProvider cacheProvider, IUserSessionService userSessionService, - IExternalIdentityLinkService externalIdentityLinkService + IExternalIdentityLinkService externalIdentityLinkService, + IMfaPolicyService mfaPolicyService, + IMfaEvidenceService mfaEvidenceService, + IMfaLoginTransactionService loginTransactions, + ISsoBrokerService ssoBroker ) { _usersService = usersService; @@ -80,8 +84,24 @@ IExternalIdentityLinkService externalIdentityLinkService _cacheProvider = cacheProvider; _userSessionService = userSessionService; _externalIdentityLinkService = externalIdentityLinkService; + _mfaPolicyService = mfaPolicyService; + _mfaEvidenceService = mfaEvidenceService; + _loginTransactions = loginTransactions; + _ssoBroker = ssoBroker; } + private readonly ISsoBrokerService _ssoBroker; + + private readonly IMfaPolicyService _mfaPolicyService; + private readonly IMfaEvidenceService _mfaEvidenceService; + private readonly IMfaLoginTransactionService _loginTransactions; + + /// Where a member whose department requires MFA sets up an authenticator (plan section 7.6 rollout). + private static string MfaEnrollmentUri => $"{SystemBehaviorConfig.ResgridBaseUrl?.TrimEnd('/')}/User/TwoFactor"; + + private const string MfaEnrollmentRequiredDescription = + "Your department requires multi-factor authentication. Set up an authenticator app in Resgrid on the web (Account, Two-Factor Authentication), then sign in again."; + /// /// Generates a token that is then used for subsquent requests to the API. /// @@ -137,20 +157,7 @@ public async Task Token() return InvalidGrant("The username or password is invalid."); } - var localLoginAllowed = await _externalIdentityLinkService.IsLocalLoginAllowedAsync( - user.Id, CancellationToken.None); - if (localLoginAllowed && userDepartment != null) - { - localLoginAllowed = await _externalIdentityLinkService.IsLocalLoginAllowedAsync( - user.Id, userDepartment.DepartmentId, CancellationToken.None); - var requiresSso = await _departmentSsoService.IsRequireSsoPolicyActiveAsync( - userDepartment.DepartmentId, CancellationToken.None); - if (requiresSso && await _departmentSsoService.IsSsoEnabledForDepartmentAsync( - userDepartment.DepartmentId, CancellationToken.None)) - localLoginAllowed = false; - } - - if (!localLoginAllowed) + if (!await IsLocalLoginAllowedAsync(user.Id, userDepartment.DepartmentId)) { audit.UserId = user.Id; await _systemAuditsService.SaveSystemAuditAsync(audit); @@ -187,8 +194,19 @@ public async Task Token() // current authenticator code must accompany the request as totp_code. This closes // the gap where only the SSO exchange enforced 2FA. The code is checked AFTER the // password so this endpoint never becomes a TOTP oracle for unauthenticated callers. + DateTime? totpVerifiedOnUtc = null; if (await _userManager.GetTwoFactorEnabledAsync(user)) { + // A client that asked for the login transaction completes its second factor there, with any accepted + // method, and never resends the password (plan section 7.5 rules 3-4). Everyone else keeps totp_code. + if (_loginTransactions.IsEnabled && string.Equals((string)request.GetParameter(MfaLoginTransactions.FlowParameter), + MfaLoginTransactions.FlowValue, StringComparison.Ordinal)) + { + var started = await BeginLoginTransactionAsync(user, userDepartment.DepartmentId, request, audit); + if (started != null) + return started; + } + var totpCode = (string)request.GetParameter("totp_code"); if (string.IsNullOrWhiteSpace(totpCode)) { @@ -224,6 +242,48 @@ public async Task Token() "The two-factor authentication code is invalid or has expired." }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + + totpVerifiedOnUtc = DateTime.UtcNow; + } + else if (await _mfaPolicyService.IsRequireMfaEnforcedAsync(userDepartment.DepartmentId)) + { + // A member with no Resgrid factor can still meet RequireMfa through the department's provider step-up, where + // it accepts that for sign-in (plan sections 7.6 row 3 and 7.8), but only through the login transaction. + if (_loginTransactions.IsEnabled && string.Equals((string)request.GetParameter(MfaLoginTransactions.FlowParameter), + MfaLoginTransactions.FlowValue, StringComparison.Ordinal) && + await _mfaPolicyService.IsMethodAcceptedAsync(userDepartment.DepartmentId, MfaMethodScope.Login, MfaEvidenceMethod.Federated) && + await _departmentSsoService.IsFederatedMfaAvailableAsync(userDepartment.DepartmentId, user.Id)) + { + var started = await BeginLoginTransactionAsync(user, userDepartment.DepartmentId, request, audit, totpEnrolled: false); + if (started != null) + return started; + } + + // The department requires MFA and this member has none enrolled (plan section 7.6 row 3): no tokens, + // and a specific error older app builds can show, with where to enroll. A client using the login + // transaction also gets a setup transaction to enroll right here (plan section 6.2); it permits nothing else. + audit.Successful = false; + audit.Data += " (mfa_enrollment_required)"; + await _systemAuditsService.SaveSystemAuditAsync(audit); + + var enrollment = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = "mfa_enrollment_required", + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = MfaEnrollmentRequiredDescription, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorUri] = MfaEnrollmentUri + }); + if (_loginTransactions.IsEnabled && string.Equals((string)request.GetParameter(MfaLoginTransactions.FlowParameter), + MfaLoginTransactions.FlowValue, StringComparison.Ordinal)) + { + var setup = await BeginSetupTransactionAsync(user, userDepartment.DepartmentId, request); + if (setup != null) + { + enrollment.Parameters["mfa_setup_transaction"] = setup.Secret; + enrollment.Parameters["mfa_expires_in"] = (long)setup.ExpiresInSeconds; + } + } + + return Forbid(enrollment, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } // Create a new ClaimsPrincipal containing the claims that @@ -248,8 +308,10 @@ public async Task Token() try { var session = await CreateApiSessionAsync(user, userDepartment?.DepartmentId, - UserSessionAuthenticationMethod.LocalPassword, refreshTokenLifetime, CancellationToken.None); + UserSessionAuthenticationMethod.LocalPassword, refreshTokenLifetime, CancellationToken.None, + loginMfaMethod: totpVerifiedOnUtc == null ? null : MfaEvidenceMethod.Totp); AddSessionClaims(principal, session); + await RecordLoginEvidenceAsync(user, session, MfaEvidenceMethod.Password, totpVerifiedOnUtc, CancellationToken.None); } catch (SessionCreationDeniedException ex) { @@ -307,11 +369,17 @@ public async Task Token() if (!validation.IsValid) { + // A locked shared session cannot refresh its way back to active (plan section 12.5.3). The tokens are + // still what unlocks it, so the client is told to unlock, not to discard them. var properties = new AuthenticationProperties(new Dictionary { [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid." + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = validation.IsLocked + ? "The shared session is locked. Unlock it, then refresh." + : "The refresh token is no longer valid." }); + if (validation.IsLocked) + properties.Parameters["shared_session_locked"] = true; return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -343,7 +411,7 @@ public async Task Token() UserId = user.Id, DepartmentId = departmentId, AuthenticationGeneration = user.AuthenticationGeneration, - ClientApplication = ResolveClientApplication(Request.Headers["X-Resgrid-Client"]), + ClientApplication = ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), DeviceName = Request.Headers["X-Resgrid-Device-Name"], DeviceType = Request.Headers["X-Resgrid-Device-Type"], OperatingSystem = Request.Headers["X-Resgrid-Operating-System"], @@ -612,9 +680,269 @@ public async Task Token() return SignIn(deptPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + else if (request != null && string.Equals(request.GrantType, MfaLoginTransactions.CompletionGrantType, StringComparison.Ordinal)) + { + return await RedeemLoginTransactionAsync(request); + } + throw new NotImplementedException("The specified grant type is not implemented."); } + /// + /// Starts the restricted login transaction after a verified password (workbook section 7.1): no tokens, only the + /// transaction secret and the methods it accepts. Null when it could not start, so the caller falls back to the + /// legacy totp_code response, which still requires the second factor. + /// + private async Task BeginLoginTransactionAsync(Model.Identity.IdentityUser user, int departmentId, OpenIddictRequest request, + SystemAudit audit, bool totpEnrolled = true) + { + MfaLoginTransactionStart start; + try + { + start = await _loginTransactions.BeginAsync(new MfaLoginTransactionRequest + { + UserId = user.Id, + DepartmentId = departmentId, + ClientApplication = ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + ClientId = request.ClientId, + FirstFactorMethod = MfaEvidenceMethod.Password, + FirstFactorVerifiedOnUtc = DateTime.UtcNow, + AuthenticationGeneration = user.AuthenticationGeneration, + Scopes = GrantableScopes(request.GetScopes()), + SharedModeRequested = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]), + InstallationLabel = Request.Headers["X-Resgrid-Device-Name"], + TotpEnrolled = totpEnrolled + }, CancellationToken.None); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "A login MFA transaction could not be started; the legacy TOTP response was used."); + return null; + } + + audit.Successful = false; + audit.Data += " (mfa_required, transaction)"; + await _systemAuditsService.SaveSystemAuditAsync(audit); + + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = "mfa_required", + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Additional verification is required." + }); + properties.Parameters["mfa_transaction"] = start.Secret; + properties.Parameters["mfa_methods"] = string.Join(" ", start.Choice.AllowedMethods); + properties.Parameters["mfa_enrolled"] = string.Join(" ", start.Choice.EnrolledMethods); + if (start.Choice.Preferred != null) + properties.Parameters["mfa_preferred"] = start.Choice.Preferred; + properties.Parameters["mfa_expires_in"] = (long)start.ExpiresInSeconds; + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + /// + /// A setup transaction (plan section 6.2): a login transaction for an account with no factor, which can only set up an + /// authenticator and then complete with it. Null when it could not start; the enrollment error stands on its own. + /// + private async Task BeginSetupTransactionAsync(Model.Identity.IdentityUser user, int departmentId, OpenIddictRequest request) + { + try + { + return await _loginTransactions.BeginAsync(new MfaLoginTransactionRequest + { + UserId = user.Id, + DepartmentId = departmentId, + ClientApplication = ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + ClientId = request.ClientId, + FirstFactorMethod = MfaEvidenceMethod.Password, + FirstFactorVerifiedOnUtc = DateTime.UtcNow, + AuthenticationGeneration = user.AuthenticationGeneration, + Scopes = GrantableScopes(request.GetScopes()), + SharedModeRequested = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]), + InstallationLabel = Request.Headers["X-Resgrid-Device-Name"], + TotpEnrolled = false + }, CancellationToken.None); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "An MFA setup transaction could not be started."); + return null; + } + } + + /// + /// Redeems a login transaction's one-use completion code for the normal token response (workbook section 7.1). The + /// account, department policy and first-factor rules are rechecked; the session records the original first-factor + /// time and the second factor actually verified. A lost response means signing in again: nothing is issued twice. + /// + private async Task RedeemLoginTransactionAsync(OpenIddictRequest request) + { + var audit = new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.Login, + Successful = false, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = $"V4 Token (MFA completion), {Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }; + + var redeemed = await _loginTransactions.RedeemAsync((string)request.GetParameter("transaction"), + (string)request.GetParameter("completion_code"), ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + CancellationToken.None); + if (!redeemed.IsUsable) + { + audit.Data += $" ({MfaLoginTransactions.ErrorCode(redeemed.Outcome)})"; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return LoginTransactionError(redeemed.Outcome); + } + + var transaction = redeemed.Transaction; + var user = await _userManager.FindByIdAsync(transaction.UserId); + audit.UserId = transaction.UserId; + audit.Username = user?.UserName; + if (user == null || !await _signInManager.CanSignInAsync(user) || await _userManager.IsLockedOutAsync(user)) + { + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant("The username or password is invalid."); + } + + // What allowed the first factor must still hold: an active membership, and password sign-in still permitted, or + // the department's SSO configuration still the one that authenticated the user. + var authenticationMethod = UserSessionAuthenticationMethod.LocalPassword; + if (transaction.DepartmentId is int departmentId) + { + var membership = await _departmentsService.GetDepartmentMemberAsync(user.Id, departmentId, bypassCache: true); + bool firstFactorStillAllowed; + if (transaction.FirstFactorMethod == (int)MfaEvidenceMethod.Password) + { + firstFactorStillAllowed = await IsLocalLoginAllowedAsync(user.Id, departmentId); + } + else + { + authenticationMethod = await SsoAuthenticationMethodAsync(departmentId, transaction.DepartmentSsoConfigId); + firstFactorStillAllowed = authenticationMethod != UserSessionAuthenticationMethod.LocalPassword; + } + + if (membership == null || membership.IsDeleted || membership.IsDisabled == true || !firstFactorStillAllowed) + { + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant("The username or password is invalid."); + } + } + else if (transaction.FirstFactorMethod != (int)MfaEvidenceMethod.Password) + { + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant("The username or password is invalid."); + } + + var principal = await _signInManager.CreateUserPrincipalAsync(user); + var granted = (transaction.Scopes ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries); + var requested = request.GetScopes(); + principal.SetScopes(requested.IsDefaultOrEmpty ? granted : granted.Intersect(requested)); + + // A brokered SSO login for an account without MFA completes with no second factor, and records none. + var method = transaction.CompletionMethod == null ? (MfaEvidenceMethod?)null : (MfaEvidenceMethod)transaction.CompletionMethod.Value; + var refreshTokenLifetime = GetRefreshTokenLifetime(transaction.ClientId); + if (SessionSecurityConfig.TrackingEnabled) + { + try + { + var session = await CreateApiSessionAsync(user, transaction.DepartmentId, authenticationMethod, + refreshTokenLifetime, CancellationToken.None, transaction.DepartmentSsoConfigId, + method, transaction.CompletionFactorReference); + AddSessionClaims(principal, session); + await RecordLoginEvidenceAsync(user, session, (MfaEvidenceMethod)transaction.FirstFactorMethod, + method == null ? null : transaction.CompletionVerifiedOnUtc, CancellationToken.None, transaction.FirstFactorVerifiedOnUtc, + method ?? MfaEvidenceMethod.Totp, transaction.CompletionFactorReference); + } + catch (SessionCreationDeniedException ex) + { + await _systemAuditsService.SaveSystemAuditAsync(audit); + return InvalidGrant(ex.FailureCode == "maximum_sessions" + ? "The department's maximum number of active sessions has been reached." + : "The user is no longer allowed to sign in to this department."); + } + } + + foreach (var claim in principal.Claims) + claim.SetDestinations(GetDestinations(claim, principal)); + + principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); + principal.SetRefreshTokenLifetime(refreshTokenLifetime); + principal.SetResources(JwtConfig.EventsClientId); + + audit.Successful = true; + audit.Data += method switch + { + null => " (SSO, no second factor required)", + MfaEvidenceMethod.RecoveryCode => " (recovery code)", + _ => $" ({MfaMethodNames.From(method.Value)})" + }; + await _systemAuditsService.SaveSystemAuditAsync(audit); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + /// + /// The session's first-factor method for an SSO login, from the department configuration that authenticated it; + /// when that configuration is gone or disabled. + /// + private async Task SsoAuthenticationMethodAsync(int departmentId, string departmentSsoConfigId) + { + var config = (await _departmentSsoService.GetSsoConfigsForDepartmentAsync(departmentId, CancellationToken.None))? + .FirstOrDefault(c => c.IsEnabled && string.Equals(c.DepartmentSsoConfigId, departmentSsoConfigId, StringComparison.Ordinal)); + return (SsoProviderType?)config?.SsoProviderType switch + { + SsoProviderType.Oidc => UserSessionAuthenticationMethod.OidcSso, + SsoProviderType.Saml2 => UserSessionAuthenticationMethod.SamlSso, + _ => UserSessionAuthenticationMethod.LocalPassword + }; + } + + private IActionResult LoginTransactionError(MfaLoginTransactionOutcome outcome) => + Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = MfaLoginTransactions.ErrorCode(outcome) ?? Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = outcome switch + { + MfaLoginTransactionOutcome.Expired => "The sign-in took too long. Sign in again.", + MfaLoginTransactionOutcome.PolicyChanged => "Your department's sign-in policy changed. Sign in again.", + MfaLoginTransactionOutcome.SessionRevoked => "Your account's sign-in state changed. Sign in again.", + MfaLoginTransactionOutcome.Unavailable => "Sign-in is temporarily unavailable. Try again.", + _ => "The sign-in could not be completed. Sign in again." + } + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + /// The scopes a password sign-in grants, limited to what the client asked for. + private static IReadOnlyCollection GrantableScopes(IEnumerable requested) => + new[] { Scopes.OpenId, Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, Scopes.Roles }.Intersect(requested ?? Array.Empty()).ToList(); + + /// + /// Whether a password may sign this user in to the department: no SSO-only account link, and the department does + /// not require SSO. Checked at the first factor and again when a login transaction is redeemed. + /// + private async Task IsLocalLoginAllowedAsync(string userId, int departmentId) + { + if (!await _externalIdentityLinkService.IsLocalLoginAllowedAsync(userId, CancellationToken.None)) + return false; + if (!await _externalIdentityLinkService.IsLocalLoginAllowedAsync(userId, departmentId, CancellationToken.None)) + return false; + + return !(await _departmentSsoService.IsRequireSsoPolicyActiveAsync(departmentId, CancellationToken.None) && + await _departmentSsoService.IsSsoEnabledForDepartmentAsync(departmentId, CancellationToken.None)); + } + + /// + /// The discovery fields every client needs to continue without a department code (plan section 7.7.4): the + /// system-encrypted department token, the department id, and whether brokered SSO can run for this department. + /// + private void AddDepartmentIdentity(Resgrid.Web.Services.Models.v4.Sso.GetDepartmentSsoConfigResultData data, Model.Department department, + DepartmentSsoConfig activeConfig) + { + data.DepartmentId = department.DepartmentId; + data.DepartmentToken = _ssoBroker.DepartmentTokenFor(department); + data.BrokeredSsoAvailable = activeConfig != null && _ssoBroker.IsEnabled && _ssoBroker.SupportsBrokered(activeConfig); + } + private IActionResult InvalidGrant(string description) { var properties = new AuthenticationProperties(new Dictionary @@ -677,6 +1005,7 @@ public async Task> GetSsoConfig( result.Data.AllowLocalLogin = true; result.Data.RequireSso = false; result.Data.RequireMfa = policy?.RequireMfa ?? false; + AddDepartmentIdentity(result.Data, department, null); return Ok(result); } @@ -687,17 +1016,19 @@ public async Task> GetSsoConfig( result.Data.AllowLocalLogin = activeConfig.AllowLocalLogin; result.Data.RequireSso = policy?.RequireSso ?? false; result.Data.RequireMfa = policy?.RequireMfa ?? false; + AddDepartmentIdentity(result.Data, department, activeConfig); if (providerType == SsoProviderType.Oidc) { result.Data.Authority = activeConfig.Authority; result.Data.ClientId = activeConfig.ClientId; // public client ID — safe to expose - result.Data.OidcRedirectUri = "resgrid://auth/callback"; + result.Data.OidcRedirectUri = LegacyOidcRedirectUri(); result.Data.OidcScopes = "openid email profile offline_access"; } else if (providerType == SsoProviderType.Saml2) { result.Data.MetadataUrl = activeConfig.MetadataUrl; + result.Data.SamlLoginUrl = LegacySamlLoginUrl(department, activeConfig); result.Data.EntityId = activeConfig.EntityId; } @@ -791,6 +1122,7 @@ public async Task> GetSsoConfigForUse result.Data.AllowLocalLogin = true; result.Data.RequireSso = false; result.Data.RequireMfa = policy?.RequireMfa ?? false; + AddDepartmentIdentity(result.Data, department, null); return Ok(result); } @@ -801,17 +1133,19 @@ public async Task> GetSsoConfigForUse result.Data.AllowLocalLogin = activeConfig.AllowLocalLogin; result.Data.RequireSso = policy?.RequireSso ?? false; result.Data.RequireMfa = policy?.RequireMfa ?? false; + AddDepartmentIdentity(result.Data, department, activeConfig); if (providerType == SsoProviderType.Oidc) { result.Data.Authority = activeConfig.Authority; result.Data.ClientId = activeConfig.ClientId; - result.Data.OidcRedirectUri = "resgrid://auth/callback"; + result.Data.OidcRedirectUri = LegacyOidcRedirectUri(); result.Data.OidcScopes = "openid email profile offline_access"; } else if (providerType == SsoProviderType.Saml2) { result.Data.MetadataUrl = activeConfig.MetadataUrl; + result.Data.SamlLoginUrl = LegacySamlLoginUrl(department, activeConfig); result.Data.EntityId = activeConfig.EntityId; } @@ -874,10 +1208,18 @@ public async Task ExternalToken( return BadRequest(new { error = "invalid_request", error_description = "provider must be 'saml2' or 'oidc'." }); } + // A relayed SAML sign-in spends its relay token and assertion on the first exchange. When that exchange stopped at the + // member's Resgrid code, the retry with the same relay token continues from the identity it validated (the app resends + // the exchange with the code, as it does for OIDC); otherwise a spent relay token signs nobody in. + string samlRelayId = null; + SamlMfaContinuation continuation = null; if (providerType == SsoProviderType.Saml2 && external_token.StartsWith(SamlRelayTokenPrefix, StringComparison.Ordinal)) { + samlRelayId = SamlRelayId(external_token); external_token = await ConsumeSamlRelayAsync(external_token); if (string.IsNullOrWhiteSpace(external_token)) + continuation = await ReadSamlMfaContinuationAsync(samlRelayId, department.DepartmentId); + if (string.IsNullOrWhiteSpace(external_token) && continuation == null) { audit.Type = (int)SystemAuditTypes.SsoLoginFailed; await _systemAuditsService.SaveSystemAuditAsync(audit); @@ -885,29 +1227,66 @@ public async Task ExternalToken( } } - // Validate the external token against the department's SSO config - var externalPrincipal = await _departmentSsoService.ValidateExternalTokenAsync( - department.DepartmentId, providerType, external_token, department.Code, cancellationToken); - - if (externalPrincipal == null) + DepartmentSsoConfig ssoConfig; + Model.Identity.IdentityUser user; + if (continuation != null) { - audit.Type = (int)SystemAuditTypes.SsoLoginFailed; - await _systemAuditsService.SaveSystemAuditAsync(audit); - return Unauthorized(new { error = "invalid_grant", error_description = "The external token could not be validated." }); + // Nothing that allowed the first exchange may have changed: the account, its generation, the membership, the SSO + // configuration, and the Resgrid factor the retry is for. + ssoConfig = await _departmentSsoService.GetSsoConfigForDepartmentAsync(department.DepartmentId, providerType, cancellationToken); + user = await _userManager.FindByIdAsync(continuation.UserId); + var member = user == null ? null : await _departmentsService.GetDepartmentMemberAsync(user.Id, department.DepartmentId, bypassCache: true); + if (user == null || user.AuthenticationGeneration != continuation.AuthenticationGeneration || ssoConfig?.IsEnabled != true || + ssoConfig.DepartmentSsoConfigId != continuation.DepartmentSsoConfigId || member == null || member.IsDeleted || member.IsDisabled == true || + !await _userManager.GetTwoFactorEnabledAsync(user)) + { + await ForgetSamlMfaContinuationAsync(samlRelayId); + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "The sign-in can no longer be finished. Sign in again." }); + } } + else + { + // Validate the external token against the department's SSO config + var externalPrincipal = await _departmentSsoService.ValidateExternalTokenAsync( + department.DepartmentId, providerType, external_token, department.Code, cancellationToken); - // Get the SSO config to pass to provisioning - var ssoConfig = await _departmentSsoService.GetSsoConfigForDepartmentAsync(department.DepartmentId, providerType, cancellationToken); + if (externalPrincipal == null) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "The external token could not be validated." }); + } - // Provision or link the user - var user = await _departmentSsoService.ProvisionOrLinkUserAsync( - department.DepartmentId, externalPrincipal, ssoConfig, department.Code, cancellationToken); + // A shared installation's sign-in must be a fresh provider sign-in: an older one may be the last operator's + // provider session, still in the installation's browser (plan section 12.5.2). The app asks the provider for one + // (OIDC prompt=login with max_age=0, SAML ForceAuthn); this refuses a provider that answered from its session. + if (await SharedInstallationAsync(department.DepartmentId, cancellationToken) && !ProviderSignInTime.IsFresh( + ProviderSignInTime.Read(externalPrincipal), DateTime.UtcNow, SharedSignInMaxAge, ProviderClockSkew)) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new + { + error = "login_required", + error_description = "This is a shared installation: sign in at your identity provider again, not with a sign-in it remembered." + }); + } - if (user == null) - { - audit.Type = (int)SystemAuditTypes.SsoLoginFailed; - await _systemAuditsService.SaveSystemAuditAsync(audit); - return Unauthorized(new { error = "invalid_grant", error_description = "No matching user found and auto-provisioning is disabled." }); + // Get the SSO config to pass to provisioning + ssoConfig = await _departmentSsoService.GetSsoConfigForDepartmentAsync(department.DepartmentId, providerType, cancellationToken); + + // Provision or link the user + user = await _departmentSsoService.ProvisionOrLinkUserAsync( + department.DepartmentId, externalPrincipal, ssoConfig, department.Code, cancellationToken); + + if (user == null) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "No matching user found and auto-provisioning is disabled." }); + } } // ── Resgrid 2FA check ──────────────────────────────────────────────────── @@ -922,6 +1301,8 @@ public async Task ExternalToken( { if (string.IsNullOrWhiteSpace(totp_code)) { + if (samlRelayId != null && continuation == null) + await KeepSamlMfaContinuationAsync(samlRelayId, user, department, ssoConfig); audit.UserId = user.Id; audit.Type = (int)SystemAuditTypes.SsoLoginFailed; await _systemAuditsService.SaveSystemAuditAsync(audit); @@ -932,14 +1313,20 @@ public async Task ExternalToken( }); } - // Verify the TOTP code against the Resgrid authenticator - var totpValid = await _userManager.VerifyTwoFactorTokenAsync( + // The SSO path shares the account lockout with every other TOTP surface (plan section 7.5 rule 6): a + // caller holding a valid IdP token does not get unlimited code guesses. + var totpValid = !await _userManager.IsLockedOutAsync(user) && await _userManager.VerifyTwoFactorTokenAsync( user, _userManager.Options.Tokens.AuthenticatorTokenProvider, totp_code); if (!totpValid) { + await _userManager.AccessFailedAsync(user); + if (samlRelayId != null && continuation == null) + await KeepSamlMfaContinuationAsync(samlRelayId, user, department, ssoConfig); + if (samlRelayId != null) + await CountSamlMfaFailureAsync(samlRelayId); audit.UserId = user.Id; audit.Type = (int)SystemAuditTypes.SsoLoginFailed; await _systemAuditsService.SaveSystemAuditAsync(audit); @@ -952,6 +1339,20 @@ public async Task ExternalToken( mfaCompleted = true; } + else if (await _mfaPolicyService.DepartmentRequiresMfaAsync(department.DepartmentId, cancellationToken)) + { + // RequireMfa has always been enforced here; say specifically that enrollment is what is missing. + audit.UserId = user.Id; + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new + { + error = "mfa_enrollment_required", + error_description = MfaEnrollmentRequiredDescription, + error_uri = MfaEnrollmentUri + }); + } + var mfaCompletedOnUtc = mfaCompleted ? DateTime.UtcNow : (DateTime?)null; // Enforce security policy (IP ranges, RequireMfa, RequireSso). // mfaCompleted reflects whether Resgrid 2FA was satisfied above. @@ -972,6 +1373,41 @@ public async Task ExternalToken( return Unauthorized(new { error = "access_denied", error_description = policyViolation }); } + // A continued SAML sign-in finishes once, however many retries race for it. + if (continuation != null && !await ClaimSamlMfaContinuationAsync(samlRelayId)) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + audit.UserId = user.Id; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "The SAML relay token is invalid, expired, or has already been used." }); + } + + // An id_token signs in once (plan section 7.7.2 item 8). It is recorded only now, at a successful completion, + // so an older build's OIDC + TOTP resend after mfa_required keeps working (section 7.7.4). + if (providerType == SsoProviderType.Oidc) + { + bool firstUse; + try + { + firstUse = await _ssoBroker.TryRecordIdTokenUseAsync(external_token, + new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(external_token).ValidTo, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "The id_token replay record could not be written; the sign-in was refused."); + return StatusCode(StatusCodes.Status503ServiceUnavailable, + new { error = "temporarily_unavailable", error_description = "Sign-in is temporarily unavailable. Try again." }); + } + + if (!firstUse) + { + audit.Type = (int)SystemAuditTypes.SsoLoginFailed; + audit.UserId = user.Id; + await _systemAuditsService.SaveSystemAuditAsync(audit); + return Unauthorized(new { error = "invalid_grant", error_description = "The external token has already been used. Sign in again." }); + } + } + // Issue an OpenIddict access token var principal = await _signInManager.CreateUserPrincipalAsync(user); @@ -984,15 +1420,17 @@ public async Task ExternalToken( Scopes.Roles }); - var refreshTokenLifetime = GetRefreshTokenLifetime(null); + var refreshTokenLifetime = GetRefreshTokenLifetime((string)null); if (SessionSecurityConfig.TrackingEnabled) { try { var session = await CreateApiSessionAsync(user, department.DepartmentId, providerType == SsoProviderType.Oidc ? UserSessionAuthenticationMethod.OidcSso : UserSessionAuthenticationMethod.SamlSso, - refreshTokenLifetime, cancellationToken, ssoConfig?.DepartmentSsoConfigId); + refreshTokenLifetime, cancellationToken, ssoConfig?.DepartmentSsoConfigId, + mfaCompleted ? MfaEvidenceMethod.Totp : null); AddSessionClaims(principal, session); + await RecordLoginEvidenceAsync(user, session, MfaEvidenceMethod.Sso, mfaCompletedOnUtc, cancellationToken); } catch (SessionCreationDeniedException ex) { @@ -1023,9 +1461,9 @@ public async Task ExternalToken( /// /// SAML 2.0 Assertion Consumer Service (ACS) relay for mobile apps. - /// Receives the SAMLResponse POST from the IdP, then redirects to the - /// resgrid:// deep-link scheme so the mobile app can complete authentication - /// via the external-token endpoint. + /// Receives the SAMLResponse POST from the IdP, then redirects to the app that started the sign-in + /// (named in its RelayState, see ) so it can complete authentication + /// via the external-token endpoint. An untagged RelayState returns to Responder, as before. /// Configure your IdP's ACS URL to point here: /// POST /api/v4/connect/saml-mobile-callback?departmentCode=DEPT /// @@ -1038,8 +1476,15 @@ public async Task SamlMobileCallback( [FromQuery] string departmentToken, [FromQuery] string departmentCode, [FromForm] string SAMLResponse, + [FromForm] string RelayState, CancellationToken cancellationToken) { + // A response to a brokered AuthnRequest carries our RelayState; it is validated against that transaction (with + // InResponseTo) and never relayed. Everything else keeps the legacy relay for older app builds. + if (_ssoBroker.IsBrokeredRelayState(RelayState)) + return SsoCallbackResponse(await _ssoBroker.CompleteSamlCallbackAsync(RelayState, SAMLResponse, + IpAddressHelper.GetRequestIP(Request, true), cancellationToken)); + if (string.IsNullOrWhiteSpace(SAMLResponse) || SAMLResponse.Length > 2_800_000) return BadRequest(new { error = "invalid_request", error_description = "SAMLResponse is required and must be within the supported size limit." }); @@ -1059,14 +1504,105 @@ public async Task SamlMobileCallback( return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "temporarily_unavailable", error_description = "SAML login relay is temporarily unavailable." }); - var encodedResponse = Uri.EscapeDataString($"{SamlRelayTokenPrefix}{relayId}"); var callbackToken = _encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"); - var encodedToken = Uri.EscapeDataString(callbackToken); - var deepLink = $"resgrid://auth/callback?saml_response={encodedResponse}&department_token={encodedToken}"; - return Redirect(deepLink); + // Back to the app that started the sign-in, with its RelayState echoed so it can reject a sign-in it did not + // start. The link carries a single-use relay token, so no cache may keep it. + var appReturn = LegacySamlRelay.For(RelayState); + Response.Headers.CacheControl = "no-store"; + Response.Headers.Pragma = "no-cache"; + return Redirect(LegacySamlRelay.DeepLink(appReturn, $"{SamlRelayTokenPrefix}{relayId}", callbackToken)); + } + + /// + /// The IdP's OIDC redirect for brokered SSO (passkey plan section 7.7.2 step 2). Departments register + /// {api}/api/v4/connect/oidc-callback with their IdP. The server exchanges the code and validates the id_token + /// (state, nonce, PKCE), then sends the browser to the client's registered return target with a one-time + /// sso_code. No token or assertion is ever in the URL. + /// + [HttpGet("oidc-callback")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task OidcCallback([FromQuery] string state, [FromQuery] string code, [FromQuery] string error, + CancellationToken cancellationToken) => + SsoCallbackResponse(await _ssoBroker.CompleteOidcCallbackAsync(state, code, error, IpAddressHelper.GetRequestIP(Request, true), cancellationToken)); + + /// + /// Redirects to the registered return target when the transaction is known; otherwise there is nowhere trusted to + /// send the browser, so the callback answers with the error itself. + /// + private IActionResult SsoCallbackResponse(SsoCallbackResult result) + { + Response.Headers.CacheControl = "no-store"; + Response.Headers.Pragma = "no-cache"; + if (result.RedirectUrl != null) + return Redirect(result.RedirectUrl); + + return StatusCode(result.Outcome == SsoBrokerOutcome.ServiceUnavailable ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status400BadRequest, + new { error = SsoBrokerOutcomes.ErrorCode(result.Outcome) ?? "sso_failed", error_description = "The sign-in could not be completed. Start again from the app." }); + } + + /// + /// Starts a legacy (unbrokered) SAML sign-in for an app (workbook section 12, slice 35). An app cannot build a SAML + /// AuthnRequest, so it opens this page, which sends the browser to the department's IdP with one. The RelayState must be + /// the app's own tagged value (unit.<nonce>): the IdP returns it with the response, and the relay + /// () sends the member back to that app and echoes it. Anything else is refused, so + /// this page never names another destination. Nothing is stored here. + /// + [HttpGet("saml-mobile-login")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task SamlMobileLogin([FromQuery] string departmentToken, [FromQuery] string departmentCode, [FromQuery] string relayState, + CancellationToken cancellationToken, [FromQuery] bool forceAuthn = false) + { + Response.Headers.CacheControl = "no-store"; + Response.Headers.Pragma = "no-cache"; + if (LegacySamlRelay.For(relayState).EchoedRelayState == null) + return BadRequest(new { error = "invalid_request", error_description = "Start the sign-in from the app." }); + + var department = await ResolveDepartmentAsync(departmentToken, departmentCode); + var config = department == null + ? null + : await _departmentSsoService.GetSsoConfigForDepartmentAsync(department.DepartmentId, SsoProviderType.Saml2, cancellationToken); + var signIn = config?.IsEnabled == true ? _ssoBroker.LegacySamlSignInUrl(config, department.Code, relayState, forceAuthn) : null; + if (signIn == null) + return BadRequest(new { error = "sso_unavailable", error_description = "Single sign-on is not available for this department." }); + + return Redirect(signIn); } + /// How long ago the provider may have authenticated a shared installation's member (the reauthentication window). + private static TimeSpan SharedSignInMaxAge => TimeSpan.FromSeconds(Math.Max(30, SsoConfig.ReauthenticationMaxAgeSeconds)); + + private static readonly TimeSpan ProviderClockSkew = TimeSpan.FromMinutes(2); + + /// + /// A sign-in from a shared installation: it says so, or the department makes this app's sessions shared (the broker's + /// rule for its own round trips). + /// + private async Task SharedInstallationAsync(int departmentId, CancellationToken cancellationToken) => + SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]) || + SharedSessionRules.IsRequiredFor(await _departmentSsoService.GetSecurityPolicyForDepartmentAsync(departmentId, cancellationToken), + ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header])); + + /// + /// This server's legacy SAML start page for the department, when its configuration can start a sign-in. Discovery calls + /// it only for a SAML configuration. + /// + private string LegacySamlLoginUrl(Model.Department department, DepartmentSsoConfig config) => + config != null && _ssoBroker.SupportsBrokered(config) + ? $"{SystemBehaviorConfig.ResgridApiBaseUrl?.TrimEnd('/')}{SsoConfig.SamlLoginPath}?departmentToken={Uri.EscapeDataString(_ssoBroker.DepartmentTokenFor(department))}" + : null; + + /// + /// The legacy OIDC redirect URI for the app asking (X-Resgrid-Client): each app has its own scheme, and the + /// department's IdP must list each one. A caller that names no app gets Responder's, as every caller did before. + /// + private string LegacyOidcRedirectUri() => + LegacyAppCallbacks.For(ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header])).Callback; + /// /// Decrypts a departmentToken (format: {departmentId}:{departmentCode}) produced by the /// web UI and returns the resolved Department, or null if the token is invalid. @@ -1100,13 +1636,85 @@ public async Task SamlMobileCallback( return null; } - private async Task ConsumeSamlRelayAsync(string relayToken) + /// The 64 hex digits of a relay token, or null when it is not one. + private static string SamlRelayId(string relayToken) => + relayToken?.Length == SamlRelayTokenPrefix.Length + 64 && relayToken.StartsWith(SamlRelayTokenPrefix, StringComparison.Ordinal) && + relayToken[SamlRelayTokenPrefix.Length..].All(Uri.IsHexDigit) + ? relayToken[SamlRelayTokenPrefix.Length..] + : null; + + /// What a relayed SAML exchange that stopped at the member's Resgrid code validated, for the code retry. + private sealed record SamlMfaContinuation(string UserId, int DepartmentId, string DepartmentSsoConfigId, long AuthenticationGeneration); + + /// + /// Keeps what the first exchange validated under its relay token, encrypted, for the relay's own lifetime (not sliding), + /// so the member's code retry can finish the sign-in the spent assertion can no longer start again. + /// + private async Task KeepSamlMfaContinuationAsync(string relayId, Model.Identity.IdentityUser user, Model.Department department, DepartmentSsoConfig config) + { + try + { + var value = string.Join("|", user.Id, department.DepartmentId.ToString(CultureInfo.InvariantCulture), config?.DepartmentSsoConfigId ?? "", + user.AuthenticationGeneration.ToString(CultureInfo.InvariantCulture)); + await _cacheProvider.SetStringAsync(GetSamlRelayMfaCacheKey(relayId), _encryptionService.Encrypt(value), SamlRelayLifetime); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // Without it the retry is refused as a spent relay token and the member starts again, as before. + Logging.LogException(ex, "The SAML code-retry state could not be kept."); + } + } + + private async Task ReadSamlMfaContinuationAsync(string relayId, int departmentId) { - if (relayToken.Length != SamlRelayTokenPrefix.Length + 64) + if (relayId == null) return null; - var relayId = relayToken[SamlRelayTokenPrefix.Length..]; - if (relayId.Any(character => !Uri.IsHexDigit(character))) + try + { + var stored = await _cacheProvider.GetStringAsync(GetSamlRelayMfaCacheKey(relayId)); + if (string.IsNullOrWhiteSpace(stored)) + return null; + + var parts = _encryptionService.Decrypt(stored).Split('|'); + return parts.Length == 4 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var storedDepartment) && + storedDepartment == departmentId && long.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out var generation) + ? new SamlMfaContinuation(parts[0], storedDepartment, parts[2], generation) + : null; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "The SAML code-retry state could not be read."); + return null; + } + } + + /// A wrong code on a relayed SAML sign-in; the retry state ends after the transaction attempt limit. + private async Task CountSamlMfaFailureAsync(string relayId) + { + var failures = await _cacheProvider.IncrementAsync(GetSamlRelayMfaFailuresCacheKey(relayId), SamlRelayLifetime); + if (failures == 0 || failures >= Math.Max(1, TwoFactorConfig.LoginMfaTransactionMaxAttempts)) + await ForgetSamlMfaContinuationAsync(relayId); + } + + /// The one retry that finishes the sign-in: atomic, so racing retries cannot both sign in. + private async Task ClaimSamlMfaContinuationAsync(string relayId) + { + var claimed = await _cacheProvider.IncrementAsync(GetSamlRelayMfaUseCacheKey(relayId), SamlRelayLifetime) == 1; + await ForgetSamlMfaContinuationAsync(relayId); + return claimed; + } + + private async Task ForgetSamlMfaContinuationAsync(string relayId) + { + if (relayId != null) + await _cacheProvider.RemoveAsync(GetSamlRelayMfaCacheKey(relayId)); + } + + private async Task ConsumeSamlRelayAsync(string relayToken) + { + var relayId = SamlRelayId(relayToken); + if (relayId == null) return null; // Increment is atomic in Redis. Only the first exchange is allowed to read the assertion, @@ -1135,9 +1743,16 @@ private async Task ConsumeSamlRelayAsync(string relayToken) private static string GetSamlRelayUseCacheKey(string relayId) => $"Sso:SamlRelayUse:{relayId}"; - private TimeSpan GetRefreshTokenLifetime(OpenIddictRequest request) + private static string GetSamlRelayMfaCacheKey(string relayId) => $"Sso:SamlRelayMfa:{relayId}"; + + private static string GetSamlRelayMfaFailuresCacheKey(string relayId) => $"Sso:SamlRelayMfaFailures:{relayId}"; + + private static string GetSamlRelayMfaUseCacheKey(string relayId) => $"Sso:SamlRelayMfaUse:{relayId}"; + + private TimeSpan GetRefreshTokenLifetime(OpenIddictRequest request) => GetRefreshTokenLifetime(request?.ClientId); + + private TimeSpan GetRefreshTokenLifetime(string clientId) { - var clientId = request?.ClientId; var isTrustedLongLivedClient = !string.IsNullOrWhiteSpace(clientId) && (OidcConfig.TrustedLongLivedClientIds ?? string.Empty) .Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) @@ -1175,14 +1790,16 @@ private TimeSpan GetRefreshTokenLifetime(OpenIddictRequest request) private async Task CreateApiSessionAsync(Model.Identity.IdentityUser user, int? departmentId, UserSessionAuthenticationMethod authenticationMethod, TimeSpan refreshTokenLifetime, - CancellationToken cancellationToken, string departmentSsoConfigId = null) + CancellationToken cancellationToken, string departmentSsoConfigId = null, MfaEvidenceMethod? loginMfaMethod = null, + string loginMfaFactorReference = null) { - return await _userSessionService.CreateSessionAsync(new SessionIssueContext + var session = await _userSessionService.CreateSessionAsync(new SessionIssueContext { UserId = user.Id, DepartmentId = departmentId, AuthenticationGeneration = user.AuthenticationGeneration, - ClientApplication = ResolveClientApplication(Request.Headers["X-Resgrid-Client"]), + ClientApplication = ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), + SharedModeRequested = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]), DeviceName = Request.Headers["X-Resgrid-Device-Name"], DeviceType = Request.Headers["X-Resgrid-Device-Type"], OperatingSystem = Request.Headers["X-Resgrid-Operating-System"], @@ -1192,27 +1809,76 @@ private async Task CreateApiSessionAsync(Model.Identity.IdentityUse DepartmentSsoConfigId = departmentSsoConfigId, ExpiresOn = DateTime.UtcNow.Add(refreshTokenLifetime), IpAddress = IpAddressHelper.GetRequestIP(Request, true), - UserAgent = Request.Headers["User-Agent"] + UserAgent = Request.Headers["User-Agent"], + LoginMfaMethod = loginMfaMethod, + LoginMfaFactorReference = loginMfaFactorReference }, cancellationToken); + + if (session?.SharedMode == true) + { + try + { + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.SharedSessionStarted, + DepartmentId = session.DepartmentId, + UserId = user.Id, + Username = user.UserName, + TargetUserId = user.Id, + SessionId = SharedSessionAudit.SessionSuffix(session.UserSessionId), + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + CorrelationId = HttpContext.TraceIdentifier, + Data = SharedSessionAudit.Describe("started", session, + (SharedModeSource)session.SharedModeSource == SharedModeSource.DepartmentRequired ? "department required" : "installation requested"), + LoggedOn = DateTime.UtcNow + }, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "Shared session start audit failed."); + } + } + + return session; } - private static UserSessionClientApplication ResolveClientApplication(string value) + /// + /// Records what this sign-in verified as server-side evidence for the new API session (passkey plan section 5.3), so + /// a TOTP sign-in satisfies a following step-up window the same way it does on Web. A failure here never blocks the + /// sign-in; the user is simply asked to verify again for a sensitive operation. + /// + private async Task RecordLoginEvidenceAsync(Model.Identity.IdentityUser user, UserSession session, MfaEvidenceMethod firstFactor, + DateTime? secondFactorOnUtc, CancellationToken cancellationToken, DateTime? firstFactorOnUtc = null, + MfaEvidenceMethod secondFactorMethod = MfaEvidenceMethod.Totp, string factorReference = null) { - if (string.IsNullOrWhiteSpace(value)) - return UserSessionClientApplication.Api; - - return value.Trim().ToLowerInvariant() switch - { - "web" => UserSessionClientApplication.Web, - "responder" => UserSessionClientApplication.Responder, - "unit" => UserSessionClientApplication.Unit, - "dispatch" => UserSessionClientApplication.Dispatch, - "bigboard" => UserSessionClientApplication.BigBoard, - "command" => UserSessionClientApplication.Command, - "ic" => UserSessionClientApplication.Command, - "mcp" => UserSessionClientApplication.Mcp, - _ => UserSessionClientApplication.Api - }; + var sessionKey = MfaEvidence.TrackedSessionKey(session?.UserSessionId); + if (sessionKey == null) + return; + + try + { + var client = (UserSessionClientApplication)session.ClientApplication; + // A login transaction carries the original first-factor time, so a completed login is never fresher than + // the password or SSO verification it began with (plan section 5.2). + await _mfaEvidenceService.RecordAsync(user.Id, sessionKey, client, MfaEvidenceKind.FirstFactor, firstFactor, + MfaEvidencePurpose.Login, firstFactorOnUtc ?? DateTime.UtcNow, user.AuthenticationGeneration, session.DepartmentId, + cancellationToken: cancellationToken); + if (secondFactorOnUtc != null) + { + // A recovery code is recorded as recovery and never satisfies MFA later (plan section 6.1 item 10). + var recovery = secondFactorMethod == MfaEvidenceMethod.RecoveryCode; + await _mfaEvidenceService.RecordAsync(user.Id, sessionKey, client, recovery ? MfaEvidenceKind.Recovery : MfaEvidenceKind.SecondFactor, + secondFactorMethod, MfaEvidencePurpose.Login, secondFactorOnUtc.Value, user.AuthenticationGeneration, session.DepartmentId, + factorReference, cancellationToken); + } + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "Failed to record API sign-in MFA evidence."); + } } private static void AddSessionClaims(ClaimsPrincipal principal, UserSession session) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs index 9c6b79b38..0ec873daa 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs @@ -61,8 +61,12 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS IDepartmentLockService departmentLockService, IProtectedFieldCatalog protectedFieldCatalog, IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, UserManager userManager, ICacheProvider cacheProvider, - IProtectedDataGrantService grantService, IAdpReleaseService adpRelease, Resgrid.Model.Repositories.IAdpAuditRepository adpAudit) + IProtectedDataGrantService grantService, IAdpReleaseService adpRelease, Resgrid.Model.Repositories.IAdpAuditRepository adpAudit, + IMfaEvidenceService mfaEvidence, IMfaPolicyService mfaPolicy, IAdpStepUpService adpStepUp, IMfaCredentialStateService credentialStates) { + _adpStepUp = adpStepUp; + _credentialStates = credentialStates; + _mfaPolicy = mfaPolicy; _dataProtectionService = dataProtectionService; _departmentLockService = departmentLockService; _protectedFieldCatalog = protectedFieldCatalog; @@ -73,8 +77,14 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS _grantService = grantService; _adpRelease = adpRelease; _adpAudit = adpAudit; + _mfaEvidence = mfaEvidence; } + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaPolicyService _mfaPolicy; + private readonly IAdpStepUpService _adpStepUp; + private readonly IMfaCredentialStateService _credentialStates; + /// /// Value-free ADP capability report for the caller's department: durable state, catalog and /// policy versions, step-up window, egress summary, and lock state. Never returns protected @@ -160,59 +170,34 @@ public async Task> Capabilities() [Authorize] public async Task> RequestGrant() { - var clientApp = int.TryParse(User.FindFirst(Model.Security.SessionClaimTypes.ClientApp)?.Value, out var parsed) - ? (UserSessionClientApplication)parsed - : UserSessionClientApplication.Api; - - // The exemption answer and the epoch the grant is stamped with come from ONE policy - // snapshot. Asking for them separately let a revocation land in between and mint a grant - // carrying the epoch that revocation had just bumped — a grant that outlived its own - // revocation. - var decision = await _dataProtectionService.GetStepUpDecisionForClientAsync(DepartmentId, clientApp); - - if (decision.StepUpRequired) - return Problem(type: "step_up_required", - title: "This department requires second-factor verification before protected values are shown.", - statusCode: StatusCodes.Status401Unauthorized); - - if (!_grantService.CanIssueGrants) - return Problem(type: "grants_not_configured", title: "Protected data grants are not configured.", - statusCode: StatusCodes.Status503ServiceUnavailable); - - var windowMinutes = decision.StepUpWindowMinutes > 0 - ? decision.StepUpWindowMinutes - : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; - windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); - - var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest - { - UserId = UserId, - DepartmentId = DepartmentId, - SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, - ClientApp = (int)clientApp, - PolicyEpoch = decision.PolicyEpoch, - WindowMinutes = windowMinutes, - Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, - MfaAtUtc = DateTime.UtcNow, - StepUpExempt = true - }); - await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", - Operation = "grant-issued", Outcome = "step-up-exempt", ActorId = UserId, CorrelationId = issued.GrantId }); - - var exemptResult = new StepUpResult + var issued = await _adpStepUp.IssueExemptAsync(Caller()); + if (issued.Outcome == Model.Security.AdpGrantOutcome.StepUpRequired) { - GrantId = issued.GrantId, - GrantToken = issued.Token, - StepUpExpiresOnUtc = issued.ExpiresOnUtc.ToString("O"), - StepUpWindowMinutes = windowMinutes, - PageSize = 1, - Status = ResponseHelper.Success - }; + // This session's own recent sign-in or unlock MFA, where the department accepts reusing it (plan section 9.1). + var reused = await _adpStepUp.IssueFromRecentEvidenceAsync(Caller()); + if (!reused.Succeeded) + return Problem(type: "step_up_required", + title: "This department requires second-factor verification before protected values are shown.", + statusCode: StatusCodes.Status401Unauthorized); + + issued = reused; + } - ResponseHelper.PopulateV4ResponseData(exemptResult); - return exemptResult; + return Grant(issued); } + /// + /// A grant from this session's recent sign-in or shared-unlock MFA, with no new prompt (plan sections 7.6 row 9 and 9.1): only + /// where the department accepts reusing that method for protected data, and only until the original verification's window + /// ends. step_up_required when nothing qualifies; the client then verifies with VerifyStepUp or another method. + /// + [HttpPost("RequestGrantFromRecentMfa")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> RequestGrantFromRecentMfa() => + Grant(await _adpStepUp.IssueFromRecentEvidenceAsync(Caller())); + [HttpPost("VerifyStepUp")] [AllowDuringDepartmentLock] [ProducesResponseType(StatusCodes.Status200OK)] @@ -223,8 +208,8 @@ public async Task> VerifyStepUp([FromBody] VerifyStep return Problem(type: "invalid_totp", title: "A verification code is required.", statusCode: StatusCodes.Status400BadRequest); - // Brute-force limiter (fail open on cache faults: lockout also guards below via TOTP - // time-step; a cache outage must not disable step-up entirely). + // Brute-force limiter. It fails open on cache faults so an outage does not disable step-up; replay is + // still blocked because ResgridAuthenticatorTokenProvider accepts each TOTP time step once, in the database. var attempts = await _cacheProvider.IncrementAsync($"AdpStepUpAttempts_{UserId}", StepUpAttemptWindow); if (attempts > StepUpMaxAttempts) return Problem(type: "too_many_attempts", @@ -241,58 +226,184 @@ public async Task> VerifyStepUp([FromBody] VerifyStep title: "Two-factor authentication is not enrolled for this account. Enroll an authenticator app in account security settings first.", statusCode: StatusCodes.Status409Conflict); + // ADP step-up shares the account lockout with sign-in and every other TOTP surface (passkey plan section 7.5 rule 6). + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", + title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + var valid = await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, input.Code.Trim()); await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", Operation = "mfa-verify", Outcome = valid ? "verified" : "denied", ActorId = UserId }); if (!valid) + { + await _userManager.AccessFailedAsync(user); return Problem(type: "invalid_totp", title: "The verification code is invalid or has expired.", statusCode: StatusCodes.Status401Unauthorized); + } - var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); - var windowMinutes = policy?.StepUpWindowMinutes > 0 - ? policy.StepUpWindowMinutes - : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + await _userManager.ResetAccessFailedCountAsync(user); - // Same clamp IssueGrant applies: the advertised window must never exceed the grant's - // actual lifetime, or clients would keep protected values visible past expiry. - windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + // The verified code becomes AdpStepUp evidence and, through the one issuer every method shares, a grant whose + // expiry runs from this verification (passkey plan sections 8.1 and 9.2). + return Grant(await _adpStepUp.IssueForTotpAsync(Caller(user), DateTime.UtcNow)); + } - var result = new StepUpResult + /// + /// How the caller can verify for this department's protected data now: the methods it has that the department + /// accepts, and which to show first (passkey plan section 7.5 rule 5). Advisory; every verification rechecks. + /// + [HttpGet("StepUpMethods")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> StepUpMethods(System.Threading.CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "protected_access_denied", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + + var choice = await _adpStepUp.GetMethodChoiceAsync(Caller(user), await _userManager.GetTwoFactorEnabledAsync(user), cancellationToken); + var result = new AdpStepUpMethodsResult { - GrantId = null, - StepUpExpiresOnUtc = DateTime.UtcNow.AddMinutes(windowMinutes).ToString("O"), - StepUpWindowMinutes = windowMinutes + Data = new AdpStepUpMethodsResultData + { + Methods = choice.AllowedMethods.Where(choice.EnrolledMethods.Contains).ToList(), + Preferred = choice.Preferred, + EnrolledMethods = choice.EnrolledMethods.ToList(), + AllowedMethods = choice.AllowedMethods.ToList() + }, + PageSize = 1, + Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } - // When signing key material is configured (identity tier), the verification mints a real - // Protected Data Grant bound to user, department, session, client app, policy epoch and - // this moment's MFA. Without it, the response keeps the pre-broker shape (null grant). - if (_grantService.CanIssueGrants) + /// + /// Assertion options for a passkey bound to this app, for this department's protected data (passkey plan section 8.1). + /// Needs a tracked session; the department and deployment must accept passkeys for protected data. + /// + [HttpPost("PasskeyOptions")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> PasskeyOptions(System.Threading.CancellationToken cancellationToken) + { + var start = await _adpStepUp.BeginPasskeyAsync(Caller(await _userManager.FindByIdAsync(UserId)), cancellationToken); + if (!start.Succeeded) + return Problem(type: Model.Security.PasskeyOutcomes.ErrorCode(start.Outcome), title: ApiPasskeys.TitleFor(start.Outcome), + statusCode: ApiPasskeys.StatusFor(start.Outcome)); + + var result = new Models.v4.Passkeys.PasskeyCeremonyResult { - var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + Data = new Models.v4.Passkeys.PasskeyCeremonyResultData { RequestId = start.RequestId, Options = ApiPasskeys.Options(start.OptionsJson) }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// Verifies the passkey assertion and returns a passkey grant (the same as a code). + [HttpPost("VerifyPasskey")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> VerifyPasskey([FromBody] AdpPasskeyStepUpInput input, System.Threading.CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(input?.RequestId) || input.Credential == null) + return Problem(type: "invalid_request", title: "A passkey response is required.", statusCode: StatusCodes.Status400BadRequest); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "protected_access_denied", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + // A passkey is not a guessable secret: its challenge carries its own attempt limit, as at Mfa/VerifyStepUp. + return Grant(await _adpStepUp.CompletePasskeyAsync(Caller(user), input.RequestId, ApiPasskeys.CredentialJson(input.Credential), cancellationToken)); + } + + /// + /// Uses an approved Responder request (MfaApproval/Request, purpose adp, from this session and department) + /// once, and returns a passkey_approval grant. Answers 409 approval_pending until it is decided. + /// + [HttpPost("CompleteApproval")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> CompleteApproval([FromBody] AdpApprovalStepUpInput input, System.Threading.CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(input?.ApprovalRequestId)) + return Problem(type: "invalid_request", title: "An approval request is required.", statusCode: StatusCodes.Status400BadRequest); + + return Grant(await _adpStepUp.CompleteApprovalAsync(Caller(await _userManager.FindByIdAsync(UserId)), input.ApprovalRequestId, cancellationToken)); + } + + /// + /// Redeems a brokered provider step-up (Sso/Begin, purpose adp_step_up) once and returns a federated + /// grant, where the department accepts its provider's MFA for protected data (passkey plan section 7.8). + /// + [HttpPost("CompleteFederated")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> CompleteFederated([FromBody] AdpFederatedStepUpInput input, System.Threading.CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(input?.SsoTransactionId) || string.IsNullOrWhiteSpace(input.SsoCode)) + return Problem(type: "invalid_request", title: "A provider step-up is required.", statusCode: StatusCodes.Status400BadRequest); + + return Grant(await _adpStepUp.CompleteFederatedAsync(Caller(await _userManager.FindByIdAsync(UserId)), input.SsoTransactionId, input.SsoCode, + input.CodeVerifier, cancellationToken)); + } + + /// The caller as the grant issuer sees it: this user, department and validated session (never client-supplied). + private Model.Security.AdpStepUpCaller Caller(Model.Identity.IdentityUser user = null) => new() + { + UserId = UserId, + UserName = user?.UserName, + DepartmentId = DepartmentId, + Session = HttpProtectedGrantContext.SessionOf(HttpContext), + LegacySessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApplication = int.TryParse(User.FindFirst(Model.Security.SessionClaimTypes.ClientApp)?.Value, out var client) + ? (UserSessionClientApplication)client + : UserSessionClientApplication.Api, + AccountAuthenticationGeneration = user?.AuthenticationGeneration ?? 0, + EvidenceSessionKey = ApiStepUpEvidence.SessionKey(HttpContext), + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + AuditSystem = SystemAuditSystems.Api + }; + + private ActionResult Grant(Model.Security.AdpGrantIssue issued) + { + if (!issued.Succeeded) + return Problem(type: issued.ErrorCode, title: issued.Outcome switch { - UserId = UserId, - DepartmentId = DepartmentId, - SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, - ClientApp = int.TryParse(User.FindFirst(Model.Security.SessionClaimTypes.ClientApp)?.Value, out var clientApp) - ? clientApp - : (int)UserSessionClientApplication.Api, - PolicyEpoch = policy?.PolicyEpoch ?? 0, - WindowMinutes = windowMinutes, - Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, - MfaAtUtc = DateTime.UtcNow - }); - await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", - Operation = "grant-issued", Outcome = "mfa-verified", ActorId = UserId, CorrelationId = issued.GrantId }); - - result.GrantId = issued.GrantId; - result.GrantToken = issued.Token; - result.StepUpExpiresOnUtc = issued.ExpiresOnUtc.ToString("O"); - } - result.PageSize = 1; - result.Status = ResponseHelper.Success; + Model.Security.AdpGrantOutcome.NotConfigured => "Protected data grants are not configured.", + Model.Security.AdpGrantOutcome.SessionRequired => "Sign in again to verify for protected data.", + Model.Security.AdpGrantOutcome.MethodNotAllowed => "That verification method is not available for protected data here.", + Model.Security.AdpGrantOutcome.StepUpRequired => "Verify again to view protected data.", + Model.Security.AdpGrantOutcome.ApprovalPending => "The request has not been approved yet.", + Model.Security.AdpGrantOutcome.ApprovalUnavailable => "The approval could not be used. Request it again.", + Model.Security.AdpGrantOutcome.CredentialRevoked => "The credential used was removed or changed. Verify again.", + Model.Security.AdpGrantOutcome.VerificationFailed => "The verification failed.", + Model.Security.AdpGrantOutcome.InvalidRequest => "The request is not valid.", + _ => "Protected data is temporarily unavailable. Try again." + }, statusCode: Model.Security.AdpGrantOutcomes.StatusFor(issued.Outcome)); + + var result = new StepUpResult + { + GrantId = issued.GrantId, + GrantToken = issued.Token, + StepUpExpiresOnUtc = issued.ExpiresOnUtc.ToString("O"), + StepUpWindowMinutes = issued.WindowMinutes, + PageSize = 1, + Status = ResponseHelper.Success + }; ResponseHelper.PopulateV4ResponseData(result); return result; } @@ -353,13 +464,13 @@ public async Task> RevokeOffboarding() } /// - /// MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the - /// caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after - /// fresh TOTP, absolute lifetime = the department step-up window — in the - /// X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy - /// epoch. On deployments without grant key material (CanValidateGrants false) the gate is - /// inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. - /// Returns null when the command may proceed. + /// MFA-recency gate for enrollment, offboarding and security commands (ADP plan sections 3.5 and 18, passkey plan + /// section 7.6 row 10 and section 8.4): actual MFA within the sensitive-operation window (5 minutes), shown either + /// by Mfa/VerifyStepUp evidence on this session (operation adp_management) or by a Protected Data Grant in + /// the X-Resgrid-Protected-Grant header whose own verification is that recent, bound to THIS user, session and + /// department at the CURRENT policy epoch. The department's longer data-access window never stretches this: an + /// eight-hour-old grant cannot change the protection lifecycle. Missing grant key material is not proof either; + /// the evidence path still works without it. Returns null when the command may proceed. /// /// A step-up-EXEMPT grant is refused here. Those are minted by RequestGrant without any second /// factor, for a client the department exempted from the reveal prompt (plan 3.3) — that @@ -369,22 +480,30 @@ public async Task> RevokeOffboarding() /// private async Task RequireRecentMfaAsync() { - if (!_grantService.CanValidateGrants) + var window = Model.Security.MfaStepUpOperations.WindowFor(Model.Security.MfaStepUpOperations.AdpManagement); + if (await ApiStepUpEvidence.HasRecentSecondFactorAsync(_mfaEvidence, _mfaPolicy, UserId, HttpContext, DepartmentId, + Model.Security.MfaMethodScope.Adp, window)) return null; - var token = Request.Headers[GrantHeader].ToString(); - var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); - var outcome = _grantService.ValidateGrant(token, DepartmentId, policy?.PolicyEpoch ?? 0, - requiredScope: null, out var grant); - - if (outcome != ProtectedDataGrantValidationOutcome.Valid || - grant.StepUpExempt || - !string.Equals(grant.UserId, UserId, StringComparison.OrdinalIgnoreCase)) - return Problem(type: "step_up_required", - title: "Recent multi-factor verification is required for this command. Verify your authenticator code and retry with the issued grant.", - statusCode: StatusCodes.Status403Forbidden); + if (_grantService.CanValidateGrants) + { + var token = Request.Headers[GrantHeader].ToString(); + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); + var outcome = _grantService.ValidateGrant(token, DepartmentId, policy?.PolicyEpoch ?? 0, + requiredScope: null, out var grant); + + var now = DateTime.UtcNow; + if (outcome == ProtectedDataGrantValidationOutcome.Valid && + !grant.StepUpExempt && + await Resgrid.Services.ProtectedGrantBinding.CheckAsync(grant, UserId, HttpProtectedGrantContext.SessionOf(HttpContext), policy?.StepUpWindowMinutes, + _credentialStates) == Resgrid.Model.Security.ProtectedGrantBindingOutcome.Bound && + grant.MfaAtUtc <= now.AddSeconds(30) && now - grant.MfaAtUtc <= window) + return null; + } - return null; + return Problem(type: "step_up_required", + title: "Recent multi-factor verification is required for this command. Verify a second factor (Mfa/VerifyStepUp, operation adp_management) and retry.", + statusCode: StatusCodes.Status403Forbidden); } private async Task> MapCommandOutcomeAsync(DepartmentDataProtectionEnrollmentResult outcome) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FactorRecoveryController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FactorRecoveryController.cs new file mode 100644 index 000000000..ea41a4298 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/FactorRecoveryController.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.AccountSecurity; +using Resgrid.Repositories.DataRepository.Stores; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// "I lost my authenticator" (passkey plan sections 5.4 and 6.3). A sign-in's verified first factor (its login + /// transaction) and a recovery code open a restricted recovery; its secret permits only status, staging a new + /// authenticator, completing and canceling. Completion replaces the authenticator, rotates the recovery codes, removes + /// the lost passkeys the user chose, and ends every session; the user then signs in normally. Nothing here issues a token. + /// + [Route("api/v{VersionId:apiVersion}/AccountSecurity")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [AllowAnonymous] + public class FactorRecoveryController : ControllerBase + { + private readonly UserManager _userManager; + private readonly IUserStore _userStore; + private readonly IUserMfaStateRepository _mfaState; + private readonly IMfaLoginTransactionService _loginTransactions; + private readonly IFactorRecoveryService _recoveries; + private readonly IUserPasskeyRepository _passkeys; + private readonly IAuthenticationChallengeService _challenges; + private readonly IMfaApprovalRequestRepository _approvals; + private readonly IUserSessionService _sessions; + private readonly IMfaEvidenceService _evidence; + private readonly ISecurityNoticeService _notices; + private readonly ISystemAuditsService _audits; + + public FactorRecoveryController(UserManager userManager, IUserStore userStore, + IUserMfaStateRepository mfaState, IMfaLoginTransactionService loginTransactions, IFactorRecoveryService recoveries, + IUserPasskeyRepository passkeys, IAuthenticationChallengeService challenges, IMfaApprovalRequestRepository approvals, + IUserSessionService sessions, IMfaEvidenceService evidence, ISecurityNoticeService notices, ISystemAuditsService audits) + { + _userManager = userManager; + _userStore = userStore; + _mfaState = mfaState; + _loginTransactions = loginTransactions; + _recoveries = recoveries; + _passkeys = passkeys; + _challenges = challenges; + _approvals = approvals; + _sessions = sessions; + _evidence = evidence; + _notices = notices; + _audits = audits; + } + + private UserSessionClientApplication Client => ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]); + + /// + /// Opens a recovery from a sign-in in progress (mfa_transaction) and a recovery code. The code is spent and the + /// sign-in ends; the recovery secret is returned once. A wrong code counts against the sign-in and the account lockout. + /// + [HttpPost("BeginFactorRecovery")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BeginFactorRecovery([FromBody] BeginFactorRecoveryInput input, CancellationToken cancellationToken) + { + if (!_recoveries.IsEnabled) + return Problem(type: "recovery_unavailable", title: "Recovery is not available here. Use Resgrid on the web.", statusCode: StatusCodes.Status400BadRequest); + + var opened = await _loginTransactions.OpenAsync(input?.Transaction, Client, cancellationToken); + if (!opened.IsUsable) + return Problem(type: MfaLoginTransactions.ErrorCode(opened.Outcome) ?? "mfa_transaction_invalid", title: "Sign in again, then start recovery.", + statusCode: StatusCodes.Status400BadRequest); + + var login = opened.Transaction; + var user = await _userManager.FindByIdAsync(login.UserId); + if (user == null || await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and sign in again.", + statusCode: StatusCodes.Status429TooManyRequests); + if (!await _userManager.GetTwoFactorEnabledAsync(user) || string.IsNullOrWhiteSpace(input.Code)) + return Problem(type: "invalid_recovery_code", title: "Enter one of your recovery codes.", statusCode: StatusCodes.Status400BadRequest); + + // The code is spent here, once (plan section 5.4); if the recovery then cannot open, the user uses another code. + if (!(await _userManager.RedeemTwoFactorRecoveryCodeAsync(user, input.Code.Trim())).Succeeded) + { + await _userManager.AccessFailedAsync(user); + await _loginTransactions.RecordFailedAttemptAsync(login, cancellationToken); + await AuditAsync(user, SystemAuditTypes.FactorRecoveryStarted, false, "Factor recovery refused: wrong recovery code.", cancellationToken); + return Problem(type: "invalid_recovery_code", title: "The recovery code is invalid or has already been used.", + statusCode: StatusCodes.Status401Unauthorized); + } + + await _userManager.ResetAccessFailedCountAsync(user); + await _loginTransactions.AbandonAsync(login, cancellationToken); + FactorRecoveryStart start; + try + { + start = await _recoveries.BeginAsync(login, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "A factor recovery could not be opened after its recovery code was spent."); + return Problem(type: "service_unavailable", title: "Recovery is temporarily unavailable. Try again with another recovery code.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + await AuditAsync(user, SystemAuditTypes.FactorRecoveryStarted, true, "Factor recovery started with a recovery code.", cancellationToken); + await _notices.QueueAsync(new SecurityNoticeRequest { UserId = user.Id, Kind = SecurityNoticeKind.RecoveryCodeUsed, ClientApplication = Client }, + cancellationToken); + + return Wrap(new FactorRecoveryStatusResult { Data = await StatusDataAsync(start.Transaction, start.Secret, cancellationToken) }); + } + + /// What the recovery allows next, and the passkeys the user may choose to remove. No factor secret is ever returned. + [HttpPost("FactorRecoveryStatus")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> FactorRecoveryStatus([FromBody] FactorRecoveryInput input, CancellationToken cancellationToken) + { + var (recovery, refusal) = await OpenAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + return Wrap(new FactorRecoveryStatusResult { Data = await StatusDataAsync(recovery, null, cancellationToken) }); + } + + /// Stages the replacement authenticator; the current one keeps working until the recovery completes. + [HttpPost("PrepareReplacement")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PrepareReplacement([FromBody] FactorRecoveryInput input, CancellationToken cancellationToken) + { + var (recovery, refusal) = await OpenAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var user = await _userManager.FindByIdAsync(recovery.UserId); + var (sharedKey, uri) = await AuthenticatorSetup.StageAsync(_userManager, user); + return Wrap(new ReplaceTotpOptionsResult + { + Data = new ReplaceTotpOptionsResultData + { + SharedKey = sharedKey, + AuthenticatorUri = uri, + ExpiresIn = (int)TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.StagedAuthenticatorLifetimeMinutes)).TotalSeconds + } + }); + } + + /// + /// Completes the recovery with a code from the new authenticator: it becomes the authenticator, the recovery codes are + /// replaced (returned once), the chosen passkeys are removed, and every session and piece of evidence ends. A wrong + /// code counts against the recovery; nothing changes until the code verifies. + /// + [HttpPost("CompleteFactorRecovery")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteFactorRecovery([FromBody] CompleteFactorRecoveryInput input, CancellationToken cancellationToken) + { + var (recovery, refusal) = await OpenAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var user = await _userManager.FindByIdAsync(recovery.UserId); + var stagedKey = await AuthenticatorSetup.GetStagedKeyAsync(_userManager, user); + if (stagedKey == null) + return Problem(type: "setup_expired", title: "Set up the new authenticator again.", statusCode: StatusCodes.Status400BadRequest); + + // Only the user's own active passkeys can be chosen for removal. + var active = await _passkeys.GetActiveForUserAsync(user.Id, cancellationToken); + var remove = (input.RemovePasskeyIds ?? new List()).Distinct(StringComparer.Ordinal).ToList(); + if (remove.Any(id => active.All(p => p.UserPasskeyId != id))) + return Problem(type: "passkey_not_found", title: "One of the passkeys to remove is not yours or is already removed.", + statusCode: StatusCodes.Status400BadRequest); + + if (!await AuthenticatorSetup.VerifyStagedCodeAsync(_mfaState, user, stagedKey, input.Code, cancellationToken)) + { + await _recoveries.RecordFailedAttemptAsync(recovery, cancellationToken); + return Problem(type: "invalid_totp", title: "The code from the new authenticator is invalid or has expired.", + statusCode: StatusCodes.Status401Unauthorized); + } + + // One completion per recovery: a concurrent request, an expiry or exhaustion leaves the working factor untouched. + if (!await _recoveries.TryCompleteAsync(recovery, cancellationToken)) + return Problem(type: "recovery_transaction_invalid", title: "This recovery is no longer valid. Sign in and start again.", + statusCode: StatusCodes.Status400BadRequest); + + var now = DateTime.UtcNow; + var shared = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]); + await AuthenticatorSetup.PromoteAsync(_userManager, _userStore, _mfaState, user, stagedKey, + new TotpEnrollmentContext(shared, (int)Client, Request.Headers["X-Resgrid-Device-Name"].ToString()), cancellationToken); + var codes = await AuthenticatorSetup.RetireOldAuthorityAsync(_userManager, _sessions, _evidence, user, cancellationToken); + foreach (var id in remove) + await _passkeys.TryRevokeAsync(id, user.Id, PasskeyRevocationReason.FactorRecovery, user.Id, now, cancellationToken); + await _challenges.CancelPendingForUserAsync(user.Id, cancellationToken); + await _approvals.CancelPendingForUserAsync(user.Id, MfaApprovalEndReason.ApproverRevoked, now, cancellationToken); + + await AuditAsync(user, SystemAuditTypes.FactorRecoveryCompleted, true, + $"Factor recovery completed: authenticator replaced, recovery codes rotated, {remove.Count} passkey(s) removed, all sessions ended.", + cancellationToken); + await _notices.QueueAsync(new SecurityNoticeRequest { UserId = user.Id, Kind = SecurityNoticeKind.FactorRecoveryCompleted, ClientApplication = Client }, + cancellationToken); + if (shared) + await _notices.QueueAsync(new SecurityNoticeRequest { UserId = user.Id, Kind = SecurityNoticeKind.SharedInstallationFactor, ClientApplication = Client }, + cancellationToken); + + return Wrap(new ReplaceTotpResult { Data = new ReplaceTotpResultData { RecoveryCodes = codes.ToList(), SignInAgain = true } }); + } + + /// Ends the recovery; the recovery code that opened it stays spent, and nothing about the account changes. + [HttpPost("CancelFactorRecovery")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CancelFactorRecovery([FromBody] FactorRecoveryInput input, CancellationToken cancellationToken) + { + var (recovery, refusal) = await OpenAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var outcome = await _recoveries.CancelAsync(input.Transaction, Client, cancellationToken); + if (outcome != FactorRecoveryOutcome.Usable) + return Refuse(outcome); + + var user = await _userManager.FindByIdAsync(recovery.UserId); + await AuditAsync(user, SystemAuditTypes.FactorRecoveryCanceled, true, "Factor recovery canceled.", cancellationToken); + return Wrap(new FactorRecoveryStatusResult { Data = new FactorRecoveryStatusResultData { State = "canceled" } }); + } + + private async Task<(FactorRecoveryTransaction Recovery, ObjectResult Refusal)> OpenAsync(string secret, CancellationToken cancellationToken) + { + if (!_recoveries.IsEnabled) + return (null, Problem(type: "recovery_unavailable", title: "Recovery is not available here.", statusCode: StatusCodes.Status400BadRequest)); + + var opened = await _recoveries.OpenAsync(secret, Client, cancellationToken); + return opened.IsUsable ? (opened.Transaction, null) : (null, Refuse(opened.Outcome)); + } + + private async Task StatusDataAsync(FactorRecoveryTransaction recovery, string secret, CancellationToken cancellationToken) + { + var passkeys = await _passkeys.GetActiveForUserAsync(recovery.UserId, cancellationToken); + return new FactorRecoveryStatusResultData + { + Transaction = secret, + State = "pending", + ExpiresIn = Math.Max(0, (int)(recovery.ExpiresOnUtc - DateTime.UtcNow).TotalSeconds), + NextActions = new List { "prepare_replacement", "complete", "cancel" }, + Passkeys = passkeys.Select(PasskeysController.ToData).ToList() + }; + } + + private ObjectResult Refuse(FactorRecoveryOutcome outcome) => Problem( + type: FactorRecoveryOutcomes.ErrorCode(outcome) ?? "recovery_transaction_invalid", + title: outcome switch + { + FactorRecoveryOutcome.Expired => "The recovery took too long. Sign in and start again.", + FactorRecoveryOutcome.TooManyAttempts => "Too many wrong codes. Sign in and start again with another recovery code.", + FactorRecoveryOutcome.SessionRevoked => "Your account's sign-in state changed. Sign in and start again.", + FactorRecoveryOutcome.Unavailable => "Recovery is temporarily unavailable. Try again.", + _ => "This recovery is no longer valid. Sign in and start again." + }, + statusCode: outcome switch + { + FactorRecoveryOutcome.TooManyAttempts => StatusCodes.Status429TooManyRequests, + FactorRecoveryOutcome.SessionRevoked => StatusCodes.Status401Unauthorized, + FactorRecoveryOutcome.Unavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + + private T Wrap(T result) where T : Models.v4.StandardApiResponseV4Base + { + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private Task AuditAsync(Model.Identity.IdentityUser user, SystemAuditTypes type, bool successful, string data, CancellationToken cancellationToken) => + _audits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)type, + UserId = user?.Id, + Username = user?.UserName, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = data + }, cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MfaApprovalController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MfaApprovalController.cs new file mode 100644 index 000000000..bd04a740a --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/MfaApprovalController.cs @@ -0,0 +1,376 @@ +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.MfaApproval; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Responder approval (passkey plan section 7.9; workbook section 7.4). The requesting app asks with its login + /// transaction (sign-in) or its signed-in session (step-up) and shows the returned number; the user's own Responder + /// reviews the request, types that number and approves with its passkey; the requester then completes through + /// Authentication/CompleteApproval or Mfa/VerifyStepUp. Nothing here issues a token, and nothing is issued + /// to Responder. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [AllowAnonymous] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] + public class MfaApprovalController : ControllerBase + { + private readonly IMfaApprovalService _approvals; + private readonly IMfaLoginTransactionService _loginTransactions; + private readonly IDepartmentsService _departments; + private readonly UserManager _userManager; + private readonly IMfaEvidenceService _evidence; + private readonly IMfaPolicyService _policy; + private readonly IAdpStepUpService _adpStepUp; + + public MfaApprovalController(IMfaApprovalService approvals, IMfaLoginTransactionService loginTransactions, IDepartmentsService departments, + UserManager userManager, IMfaEvidenceService evidence, IMfaPolicyService policy, IAdpStepUpService adpStepUp) + { + _adpStepUp = adpStepUp; + _evidence = evidence; + _policy = policy; + _approvals = approvals; + _loginTransactions = loginTransactions; + _departments = departments; + _userManager = userManager; + } + + private string SessionUserId => User.FindFirst(ClaimTypes.PrimarySid)?.Value; + private int? SessionDepartmentId => int.TryParse(User.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value, out var id) ? id : null; + private string IpAddress => IpAddressHelper.GetRequestIP(Request, true); + + // ── Requester ───────────────────────────────────────────────────────────────── + + /// Asks the user's Responder to approve; returns the number to show on this screen only. + [HttpPost("Request")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RequestApproval([FromBody] MfaApprovalRequestInput input, CancellationToken cancellationToken) + { + if (!_approvals.IsEnabled) + return Refuse(MfaApprovalOutcome.Unavailable); + + MfaApprovalRequester requester; + var purpose = input?.Purpose?.Trim().ToLowerInvariant(); + if (purpose == "login") + { + var (login, refusal) = await OpenLoginAsync(input.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var user = await _userManager.FindByIdAsync(login.UserId); + if (user == null || await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and sign in again.", + statusCode: StatusCodes.Status429TooManyRequests); + if (!await _loginTransactions.IsMethodAcceptedAsync(login, MfaEvidenceMethod.PasskeyApproval, cancellationToken)) + return Refuse(MfaApprovalOutcome.Unavailable); + + requester = MfaApprovalRequester.ForLoginTransaction(login, user.UserName, IpAddress); + } + else if (purpose == "step_up") + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null || string.IsNullOrWhiteSpace(SessionUserId)) + return Refuse(MfaApprovalOutcome.SessionRequired); + + requester = new MfaApprovalRequester + { + UserId = SessionUserId, + Kind = MfaApprovalRequesterKind.Session, + RequesterId = session.SessionId, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + AuthenticationGeneration = session.AuthenticationGeneration, + DepartmentId = SessionDepartmentId, + Purpose = MfaApprovalPurpose.StepUp, + Operation = input.Operation, + SharedMode = session.SharedMode, + LockVersion = session.SessionLockVersion, + IpAddress = IpAddress, + UserName = User.FindFirst(ClaimTypes.Name)?.Value, + AuditSystem = SystemAuditSystems.Api + }; + } + else if (purpose == "adp") + { + // Access to this department's protected data (passkey plan section 7.9): the ADP issuer checks the session, the + // department's ADP switches and version 2 issuance before asking, and consumes it at DataProtection/CompleteApproval. + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null || string.IsNullOrWhiteSpace(SessionUserId) || SessionDepartmentId is not int adpDepartment) + return Refuse(MfaApprovalOutcome.SessionRequired); + + var adpStart = await _adpStepUp.RequestApprovalAsync(new AdpStepUpCaller + { + UserId = SessionUserId, + UserName = User.FindFirst(ClaimTypes.Name)?.Value, + DepartmentId = adpDepartment, + Session = session, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + IpAddress = IpAddress, + AuditSystem = SystemAuditSystems.Api + }, cancellationToken); + return Started(adpStart); + } + else + { + return Refuse(MfaApprovalOutcome.InvalidRequest); + } + + return Started(await _approvals.RequestAsync(requester, cancellationToken)); + } + + private ActionResult Started(MfaApprovalStart start) + { + if (!start.Succeeded) + return Refuse(start.Outcome); + + return Wrap(new MfaApprovalRequestResult + { + Data = new MfaApprovalRequestResultData { ApprovalRequestId = start.ApprovalRequestId, MatchNumber = start.MatchNumber, ExpiresIn = start.ExpiresInSeconds } + }); + } + + /// The request's state, for the requester that made it. Poll every 2 seconds. + [HttpPost("Status")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Status([FromBody] MfaApprovalReferenceInput input, CancellationToken cancellationToken) + { + var (kind, requesterId, refusal) = await RequesterAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var found = await _approvals.GetForRequesterAsync(input.ApprovalRequestId, kind, requesterId, cancellationToken); + if (!found.Succeeded) + return Refuse(found.Outcome); + + return Wrap(new MfaApprovalStatusResult + { + Data = new MfaApprovalStatusResultData + { + State = MfaApprovalOutcomes.StateName(found.Request.EffectiveState(DateTime.UtcNow)), + ExpiresAt = found.Request.ExpiresOnUtc.ToString("O") + } + }); + } + + [HttpPost("Cancel")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Cancel([FromBody] MfaApprovalReferenceInput input, CancellationToken cancellationToken) + { + var (kind, requesterId, refusal) = await RequesterAsync(input?.Transaction, cancellationToken); + if (refusal != null) + return refusal; + + var outcome = await _approvals.CancelAsync(input.ApprovalRequestId, kind, requesterId, cancellationToken); + if (outcome != MfaApprovalOutcome.Succeeded) + return Refuse(outcome); + + return Wrap(new MfaApprovalStatusResult { Data = new MfaApprovalStatusResultData { State = MfaApprovalOutcomes.StateName(MfaApprovalRequestState.Canceled) } }); + } + + // ── Approver (Responder) ────────────────────────────────────────────────────── + + /// The request waiting for this user's approval, for their own signed-in Responder; empty when none. + [HttpGet("Pending")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Pending(CancellationToken cancellationToken) + { + var pending = await _approvals.GetPendingForApproverAsync(Approver(), cancellationToken); + if (!pending.Succeeded) + return Refuse(pending.Outcome); + + var request = pending.Request; + if (request == null) + return Wrap(new MfaApprovalPendingResult()); + + var department = request.DepartmentId is int departmentId ? await _departments.GetDepartmentByIdAsync(departmentId) : null; + return Wrap(new MfaApprovalPendingResult + { + Data = new MfaApprovalPendingResultData + { + ApprovalRequestId = request.MfaApprovalRequestId, + RequestingApp = ApiPasskeys.ClientName((UserSessionClientApplication)request.ClientApplication), + InstallationLabel = request.InstallationLabel, + Shared = request.SharedMode, + Department = department?.Name, + Purpose = MfaApprovalOutcomes.PurposeName(request.RequestPurpose), + Operation = request.Operation, + OriginRegion = request.OriginRegion, + CreatedAt = request.CreatedOnUtc.ToString("O"), + ExpiresAt = request.ExpiresOnUtc.ToString("O"), + AttemptsRemaining = Math.Max(0, request.MaxAttempts - request.Attempts) + } + }); + } + + /// Assertion options for this Responder's approval passkeys, bound to the request. + [HttpPost("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Options([FromBody] MfaApprovalOptionsInput input, CancellationToken cancellationToken) + { + var (outcome, ceremony) = await _approvals.BeginApprovalAsync(Approver(), input?.ApprovalRequestId, cancellationToken); + if (outcome != MfaApprovalOutcome.Succeeded) + return Refuse(outcome); + if (!ceremony.Succeeded) + return Problem(type: PasskeyOutcomes.ErrorCode(ceremony.Outcome), title: ApiPasskeys.TitleFor(ceremony.Outcome), + statusCode: ApiPasskeys.StatusFor(ceremony.Outcome)); + + return Wrap(new PasskeyCeremonyResult + { + Data = new PasskeyCeremonyResultData { RequestId = ceremony.RequestId, Options = ApiPasskeys.Options(ceremony.OptionsJson) } + }); + } + + /// Approves with the number from the requesting screen and a Responder passkey assertion. + [HttpPost("Approve")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Approve([FromBody] MfaApprovalApproveInput input, CancellationToken cancellationToken) + { + var (result, passkey) = await _approvals.ApproveAsync(Approver(), input?.ApprovalRequestId, input?.MatchNumber, input?.RequestId, + ApiPasskeys.CredentialJson(input?.Credential), cancellationToken); + if (passkey != PasskeyOutcome.Succeeded) + return Problem(type: PasskeyOutcomes.ErrorCode(passkey), title: ApiPasskeys.TitleFor(passkey), statusCode: ApiPasskeys.StatusFor(passkey)); + if (result.Outcome == MfaApprovalOutcome.NumberMismatch) + return Problem(type: MfaApprovalOutcomes.ErrorCode(result.Outcome), + title: $"That is not the number on the other screen. {Math.Max(0, result.Request.MaxAttempts - result.Request.Attempts)} attempt(s) left.", + statusCode: StatusCodes.Status400BadRequest); + if (!result.Succeeded) + return Refuse(result.Outcome); + + return Wrap(new MfaApprovalDecisionResult { Data = new MfaApprovalDecisionResultData { State = MfaApprovalOutcomes.StateName(MfaApprovalRequestState.Approved) } }); + } + + /// Denies the request; not_me also ends that sign-in and suspends approval requests for 15 minutes. + [HttpPost("Deny")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Deny([FromBody] MfaApprovalDenyInput input, CancellationToken cancellationToken) + { + var reason = input?.Reason?.Trim().ToLowerInvariant() switch + { + "declined" or null or "" => MfaApprovalEndReason.Declined, + "not_me" => MfaApprovalEndReason.NotMe, + _ => (MfaApprovalEndReason)0 + }; + + var result = await _approvals.DenyAsync(Approver(), input?.ApprovalRequestId, reason, cancellationToken); + if (!result.Succeeded) + return Refuse(result.Outcome); + + return Wrap(new MfaApprovalDecisionResult { Data = new MfaApprovalDecisionResultData { State = MfaApprovalOutcomes.StateName(MfaApprovalRequestState.Denied) } }); + } + + // ── The account page (plan section 6.5) ─────────────────────────────────────── + + /// + /// Stops approval requests on one Responder installation (InstallationId from AccountSecurity/Methods), or + /// on all of them with All, which also turns approval off on every Responder passkey. Works from any app; needs + /// the authenticator or a passkey for this app within five minutes, never an approval (plan section 7.6 row 14). + /// + [HttpPost("Installations/Disable")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DisableInstallations([FromBody] DisableApprovalInstallationsInput input, + CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null || string.IsNullOrWhiteSpace(SessionUserId)) + return Refuse(MfaApprovalOutcome.SessionRequired); + + var installationId = input?.InstallationId?.Trim(); + if (input == null || input.All == !string.IsNullOrEmpty(installationId)) + return Problem(type: "invalid_request", title: "Choose one installation, or all of them.", statusCode: StatusCodes.Status400BadRequest); + + if (!await ApiStepUpEvidence.HasRecentSecondFactorAsync(_evidence, _policy, SessionUserId, HttpContext, SessionDepartmentId, MfaMethodScope.Account, + MfaStepUpOperations.WindowFor(MfaStepUpOperations.AccountSecurity), cancellationToken)) + return Problem(type: "step_up_required", + title: "Verify with your authenticator or a passkey for this app first (Mfa/VerifyStepUp, operation account_security).", + statusCode: StatusCodes.Status403Forbidden); + + var (installations, passkeys) = await _approvals.DisableInstallationsAsync(SessionUserId, input.All ? null : installationId, + new SharedSessionRequestInfo { UserName = User.FindFirst(ClaimTypes.Name)?.Value, IpAddress = IpAddress, CorrelationId = HttpContext.TraceIdentifier }, + cancellationToken); + return Wrap(new DisableApprovalInstallationsResult + { + Data = new DisableApprovalInstallationsResultData { InstallationsStopped = installations, PasskeysStopped = passkeys } + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────────────── + + /// The approver: the validated session asking, if it is one (the service checks it is a personal Responder session). + private PasskeyCaller Approver() => + string.IsNullOrWhiteSpace(SessionUserId) + ? null + : ApiPasskeys.Caller(HttpContext, SessionUserId, User.FindFirst(ClaimTypes.Name)?.Value, SessionDepartmentId); + + private async Task<(MfaLoginTransaction Transaction, ObjectResult Refusal)> OpenLoginAsync(string secret, CancellationToken cancellationToken) + { + var opened = await _loginTransactions.OpenAsync(secret, ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]), cancellationToken); + return opened.IsUsable + ? (opened.Transaction, null) + : (null, Problem(type: MfaLoginTransactions.ErrorCode(opened.Outcome) ?? "mfa_transaction_invalid", + title: "This sign-in is no longer valid. Sign in again.", statusCode: StatusCodes.Status400BadRequest)); + } + + /// The requester: the login transaction whose secret was sent, otherwise the signed-in session. + private async Task<(MfaApprovalRequesterKind Kind, string RequesterId, ObjectResult Refusal)> RequesterAsync(string transactionSecret, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(transactionSecret)) + { + var (login, refusal) = await OpenLoginAsync(transactionSecret, cancellationToken); + return refusal != null ? (default, null, refusal) : (MfaApprovalRequesterKind.LoginTransaction, login.MfaLoginTransactionId, null); + } + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + return session == null + ? (default, null, Refuse(MfaApprovalOutcome.SessionRequired)) + : (MfaApprovalRequesterKind.Session, session.SessionId, null); + } + + private T Wrap(T result) where T : Models.v4.StandardApiResponseV4Base + { + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ObjectResult Refuse(MfaApprovalOutcome outcome) => Problem( + type: MfaApprovalOutcomes.ErrorCode(outcome) ?? "approval_unavailable", + title: outcome switch + { + MfaApprovalOutcome.Unavailable => "Approve with Responder is not available here.", + MfaApprovalOutcome.Suspended => "Approval requests are paused for a few minutes. Use another verification method.", + MfaApprovalOutcome.TooManyRequests => "Too many approval requests. Wait a few minutes or use another verification method.", + MfaApprovalOutcome.Pending => "The request has not been approved yet.", + MfaApprovalOutcome.Denied => "The request was denied in Responder.", + MfaApprovalOutcome.Expired or MfaApprovalOutcome.NotFound => "The request is no longer valid. Start again.", + MfaApprovalOutcome.SessionRequired => "Sign in again to continue.", + MfaApprovalOutcome.ServiceUnavailable => "Approval is temporarily unavailable. Try again.", + _ => "The request could not be processed." + }, + statusCode: outcome switch + { + MfaApprovalOutcome.Suspended or MfaApprovalOutcome.TooManyRequests => StatusCodes.Status429TooManyRequests, + MfaApprovalOutcome.SessionRequired => StatusCodes.Status409Conflict, + MfaApprovalOutcome.Denied => StatusCodes.Status403Forbidden, + MfaApprovalOutcome.Pending => StatusCodes.Status409Conflict, + MfaApprovalOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MfaController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MfaController.cs new file mode 100644 index 000000000..1e1af670e --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/MfaController.cs @@ -0,0 +1,365 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Mfa; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// API step-up for sensitive operations (passkey plan sections 7.6 and 11): the v4 replacement for per-feature + /// verification. A verified second factor becomes server-side evidence on the caller's validated session, with its + /// method and time; it returns no token and authorizes nothing by itself. The guarded commands check the evidence. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] + public class MfaController : V4AuthenticatedApiControllerbase + { + private const int MaxAttempts = 5; + private static readonly TimeSpan AttemptWindow = TimeSpan.FromMinutes(5); + + private readonly UserManager _userManager; + private readonly IMfaPolicyService _mfaPolicy; + private readonly IMfaEvidenceService _mfaEvidence; + private readonly ICacheProvider _cacheProvider; + private readonly ISystemAuditsService _systemAudits; + private readonly IPasskeyService _passkeys; + private readonly IDepartmentSsoService _departmentSso; + private readonly ISsoBrokerService _ssoBroker; + private readonly IMfaApprovalService _approvals; + private readonly IMfaActivityService _activity; + + public MfaController(UserManager userManager, IMfaPolicyService mfaPolicy, + IMfaEvidenceService mfaEvidence, ICacheProvider cacheProvider, ISystemAuditsService systemAudits, IPasskeyService passkeys, + IDepartmentSsoService departmentSso, ISsoBrokerService ssoBroker, IMfaApprovalService approvals, IMfaActivityService activity) + { + _activity = activity; + _approvals = approvals; + _departmentSso = departmentSso; + _ssoBroker = ssoBroker; + _userManager = userManager; + _mfaPolicy = mfaPolicy; + _mfaEvidence = mfaEvidence; + _cacheProvider = cacheProvider; + _systemAudits = systemAudits; + _passkeys = passkeys; + } + + /// The methods the caller can use for , and which to show first. + [HttpGet("StepUpOptions")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> StepUpOptions([FromQuery] string operation, CancellationToken cancellationToken) + { + if (!MfaStepUpOperations.IsKnown(operation)) + return Problem(type: "invalid_request", title: "Unknown step-up operation.", statusCode: StatusCodes.Status400BadRequest); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "session_revoked", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + + // A passkey counts as enrolled only when one is bound to the app asking (plan section 3 item 14). + var caller = ApiPasskeys.Caller(HttpContext, user.Id, user.UserName, DepartmentId); + var passkeyEnrolled = caller != null && await _passkeys.HasActiveForClientAsync(user.Id, caller.ClientApplication, cancellationToken); + // Provider step-up runs through Sso/Begin with purpose step_up; never for account factors (plan section 7.8). + var federatedEnrolled = operation != MfaStepUpOperations.AccountSecurity && + await _departmentSso.IsFederatedMfaAvailableAsync(DepartmentId, user.Id, cancellationToken); + // Responder approval from the user's own phone, for another app's session (plan section 7.9). + var approvalEnrolled = caller != null && await _approvals.IsAvailableAsync(user.Id, caller.ClientApplication, cancellationToken); + var choice = await _mfaPolicy.GetMethodChoiceAsync(UserId, await _userManager.GetTwoFactorEnabledAsync(user), DepartmentId, + MfaStepUpOperations.ScopeFor(operation), passkeyEnrolled, federatedEnrolled, approvalEnrolled, cancellationToken); + var methods = choice.AllowedMethods.Where(choice.EnrolledMethods.Contains).ToList(); + + PasskeyCeremonyResultData passkey = null; + if (methods.Contains(MfaMethodNames.Passkey)) + { + // Advisory like the rest of this response: when the ceremony cannot start, the other methods still work. + var start = await _passkeys.BeginAssertionAsync(caller, AuthenticationChallengePurpose.SensitiveOperation, cancellationToken); + if (start.Succeeded) + passkey = new PasskeyCeremonyResultData { RequestId = start.RequestId, Options = ApiPasskeys.Options(start.OptionsJson) }; + } + + var result = new StepUpOptionsResult + { + Data = new StepUpOptionsResultData + { + Methods = methods, + EnrolledMethods = choice.EnrolledMethods.ToList(), + AllowedMethods = choice.AllowedMethods.ToList(), + Preferred = choice.Preferred, + EnrollmentRequired = !choice.CanVerify, + WindowMinutes = (int)MfaStepUpOperations.WindowFor(operation).TotalMinutes, + Passkey = passkey + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Verifies a second factor for operation and records it as evidence on this session. A TOTP failure counts + /// against the account lockout shared with sign-in (plan section 7.5 rule 6); a passkey is limited by its single-use + /// request instead, since a signature cannot be guessed. + /// + [HttpPost("VerifyStepUp")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> VerifyStepUp([FromBody] VerifyStepUpInput input, CancellationToken cancellationToken) + { + if (input == null || !MfaStepUpOperations.IsKnown(input.Operation)) + return Problem(type: "invalid_request", title: "Unknown step-up operation.", statusCode: StatusCodes.Status400BadRequest); + + var method = input.Method ?? MfaMethodNames.Totp; + var isPasskey = string.Equals(method, MfaMethodNames.Passkey, StringComparison.Ordinal); + var isFederated = string.Equals(method, MfaMethodNames.Federated, StringComparison.Ordinal); + var isApproval = string.Equals(method, MfaMethodNames.PasskeyApproval, StringComparison.Ordinal); + var isTotp = string.Equals(method, MfaMethodNames.Totp, StringComparison.Ordinal); + if (!isPasskey && !isFederated && !isApproval && !isTotp) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", + statusCode: StatusCodes.Status400BadRequest); + if (isTotp && string.IsNullOrWhiteSpace(input.Code)) + return Problem(type: "invalid_totp", title: "A verification code is required.", statusCode: StatusCodes.Status400BadRequest); + + // Evidence belongs to a tracked session; a token without one cannot hold it, so the client signs in again. + var sessionKey = ApiStepUpEvidence.SessionKey(HttpContext); + if (sessionKey == null) + return Problem(type: "session_required", title: "Sign in again to verify for this operation.", + statusCode: StatusCodes.Status409Conflict); + + // Brute-force limiter on top of the account lockout. It fails open on cache faults; the lockout does not. + if (await _cacheProvider.IncrementAsync($"MfaStepUpAttempts_{UserId}", AttemptWindow) > MaxAttempts) + return Problem(type: "too_many_attempts", title: "Too many verification attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "session_revoked", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + + // Provider step-up proves the provider account, so an SSO user needs no Resgrid factor for it; TOTP, passkeys and + // approval (whose Responder passkey needed TOTP to register) do. + if (!isFederated && !await _userManager.GetTwoFactorEnabledAsync(user)) + return Problem(type: "mfa_enrollment_required", + title: "Set up an authenticator app in Resgrid on the web before this operation.", + statusCode: StatusCodes.Status409Conflict); + + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + DateTime verifiedAt; + MfaEvidenceMethod evidenceMethod; + string factorReference = null; + if (isApproval) + { + var approved = await VerifyApprovalAsync(input, user, cancellationToken); + if (approved.Problem != null) + { + await AuditAsync(user, false, input.Operation, method, cancellationToken); + return approved.Problem; + } + + verifiedAt = approved.VerifiedAt; + evidenceMethod = MfaEvidenceMethod.PasskeyApproval; + factorReference = approved.FactorReference; + } + else if (isFederated) + { + var federated = await VerifyFederatedAsync(input, cancellationToken); + if (federated.Problem != null) + { + await AuditAsync(user, false, input.Operation, method, cancellationToken); + return federated.Problem; + } + + verifiedAt = federated.VerifiedAt; + evidenceMethod = MfaEvidenceMethod.Federated; + factorReference = federated.FactorReference; + } + else if (isPasskey) + { + // The department (or, for account changes, the deployment) must accept a passkey for this operation now. + if (!await _mfaPolicy.IsMethodAcceptedAsync(DepartmentId, MfaStepUpOperations.ScopeFor(input.Operation), MfaEvidenceMethod.Passkey, + cancellationToken)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", + statusCode: StatusCodes.Status400BadRequest); + + var assertion = await _passkeys.CompleteAssertionAsync(ApiPasskeys.Caller(HttpContext, user.Id, user.UserName, DepartmentId), + AuthenticationChallengePurpose.SensitiveOperation, input.RequestId, ApiPasskeys.CredentialJson(input.Credential), cancellationToken); + if (!assertion.Succeeded) + { + if (assertion.Outcome is PasskeyOutcome.VerificationFailed or PasskeyOutcome.NotRegisteredForClient) + await AuditAsync(user, false, input.Operation, method, cancellationToken); + return Problem(type: PasskeyOutcomes.ErrorCode(assertion.Outcome), title: ApiPasskeys.TitleFor(assertion.Outcome), + statusCode: ApiPasskeys.StatusFor(assertion.Outcome)); + } + + verifiedAt = assertion.VerifiedOnUtc; + evidenceMethod = MfaEvidenceMethod.Passkey; + factorReference = UserPasskey.FactorReferenceFor(assertion.Passkey.UserPasskeyId); + } + else + { + // One-time: ResgridAuthenticatorTokenProvider accepts each time step once per user, on every surface. + if (!await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, + input.Code.Replace(" ", string.Empty).Replace("-", string.Empty))) + { + await _userManager.AccessFailedAsync(user); + await AuditAsync(user, false, input.Operation, method, cancellationToken); + return Problem(type: "invalid_totp", title: "The verification code is invalid or has expired.", + statusCode: StatusCodes.Status401Unauthorized); + } + + await _userManager.ResetAccessFailedCountAsync(user); + verifiedAt = DateTime.UtcNow; + evidenceMethod = MfaEvidenceMethod.Totp; + } + + var client = HttpProtectedGrantContext.SessionOf(HttpContext)?.ClientApplication ?? (int)UserSessionClientApplication.Api; + try + { + await _mfaEvidence.RecordAsync(user.Id, sessionKey, (UserSessionClientApplication)client, MfaEvidenceKind.SecondFactor, + evidenceMethod, MfaEvidencePurpose.StepUp, verifiedAt, user.AuthenticationGeneration, DepartmentId, + factorReference, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The evidence is the whole product of this call: without it the operation would refuse anyway. + Framework.Logging.LogException(ex, "API step-up evidence could not be recorded."); + return Problem(type: "service_unavailable", title: "The verification could not be recorded. Try again.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + await AuditAsync(user, true, input.Operation, method, cancellationToken); + + var result = new VerifyStepUpResult + { + Data = new VerifyStepUpResultData + { + VerifiedAt = verifiedAt.ToString("O"), + ExpiresAt = verifiedAt.Add(MfaStepUpOperations.WindowFor(input.Operation)).ToString("O") + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Uses a Responder approval requested by this session for this operation (plan section 7.9 step 6). The department + /// must accept approval here (never security changes or account factors), and the approving passkey and Responder + /// session must still count; the evidence names them and carries the approval time. + /// + private async Task<(ObjectResult Problem, DateTime VerifiedAt, string FactorReference)> VerifyApprovalAsync(VerifyStepUpInput input, + Model.Identity.IdentityUser user, CancellationToken cancellationToken) + { + if (!await _mfaPolicy.IsMethodAcceptedAsync(DepartmentId, MfaStepUpOperations.ScopeFor(input.Operation), MfaEvidenceMethod.PasskeyApproval, + cancellationToken)) + return (Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest), + default, null); + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var found = await _approvals.GetForRequesterAsync(input.ApprovalRequestId, MfaApprovalRequesterKind.Session, session.SessionId, cancellationToken); + // A shared session's request is bound to the lock version it was made at; a lock since then voids it. + if (found.Succeeded && (found.Request.RequestPurpose != MfaApprovalPurpose.StepUp || + !string.Equals(found.Request.Operation, input.Operation, StringComparison.Ordinal) || + found.Request.LockVersion != session.SessionLockVersion)) + return (Problem(type: "approval_expired", title: "That approval was for another operation. Request it again.", + statusCode: StatusCodes.Status400BadRequest), default, null); + + var consumed = await _approvals.ConsumeAsync(input.ApprovalRequestId, MfaApprovalRequesterKind.Session, session.SessionId, user.Id, + session.AuthenticationGeneration, cancellationToken); + if (!consumed.Succeeded) + return (Problem(type: MfaApprovalOutcomes.ErrorCode(consumed.Outcome) ?? "approval_unavailable", + title: consumed.Outcome == MfaApprovalOutcome.Pending ? "The request has not been approved yet." : "The approval could not be used. Request it again.", + statusCode: consumed.Outcome switch + { + MfaApprovalOutcome.Pending => StatusCodes.Status409Conflict, + MfaApprovalOutcome.Denied => StatusCodes.Status403Forbidden, + MfaApprovalOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }), default, null); + + var approval = consumed.Request; + return (null, approval.DecidedOnUtc ?? DateTime.UtcNow, + MfaApprovalRequest.FactorReferenceFor(approval.ApproverPasskeyId, approval.ApproverSessionId)); + } + + /// + /// Redeems a provider step-up begun through Sso/Begin (purpose step_up) for this session and operation. + /// Evidence carries the provider's own authentication time and names the SSO configuration and mapping version, so a + /// mapping change retires it (plan section 7.8). + /// + private async Task<(ObjectResult Problem, DateTime VerifiedAt, string FactorReference)> VerifyFederatedAsync(VerifyStepUpInput input, + CancellationToken cancellationToken) + { + if (input.Operation == MfaStepUpOperations.AccountSecurity || + !await _mfaPolicy.IsMethodAcceptedAsync(DepartmentId, MfaStepUpOperations.ScopeFor(input.Operation), MfaEvidenceMethod.Federated, cancellationToken)) + return (Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest), + default, null); + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var redeemed = await _ssoBroker.RedeemAsync(input.SsoTransactionId, input.SsoCode, input.CodeVerifier, + (UserSessionClientApplication)session.ClientApplication, cancellationToken, SsoTransactionPurpose.StepUp); + var transaction = redeemed.Transaction; + var config = redeemed.Succeeded ? await _departmentSso.GetTestedFederatedMfaConfigAsync(DepartmentId, cancellationToken) : null; + if (!redeemed.Succeeded || transaction.DepartmentId != DepartmentId || !FederatedMfaMapping.Satisfies(transaction, config) || + !string.Equals(transaction.Operation, input.Operation, StringComparison.Ordinal) || + !string.Equals(transaction.SessionId, session.SessionId, StringComparison.Ordinal) || + // Begun before this shared session's last lock: nothing from before a lock counts after it (plan section 12.5.3). + (session.SessionLockedOnUtc != null && transaction.CreatedOnUtc <= session.SessionLockedOnUtc.Value) || + !string.Equals(transaction.ExpectedUserId, UserId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(transaction.UserId, UserId, StringComparison.OrdinalIgnoreCase) || + transaction.AuthenticationGeneration != session.AuthenticationGeneration) + return (Problem(type: redeemed.Succeeded ? "federated_mfa_not_satisfied" : SsoBrokerOutcomes.ErrorCode(redeemed.Outcome) ?? "sso_failed", + title: "The provider step-up could not be verified for this operation. Start it again.", statusCode: StatusCodes.Status401Unauthorized), + default, null); + + return (null, transaction.AuthenticatedOnUtc ?? DateTime.UtcNow, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion)); + } + + private async Task AuditAsync(Model.Identity.IdentityUser user, bool successful, string operation, string method, CancellationToken cancellationToken) + { + await _systemAudits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.TwoFactorStepUpVerified, + UserId = user.Id, + Username = user.UserName, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = $"API step-up for {operation} with {method}: {(successful ? "verified" : "failed")}." + }, cancellationToken); + + // A success is recorded with its evidence; a failure has none, so it is recorded here (plan section 6.5). + if (!successful) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + await _activity.RecordAsync(new MfaActivityEntry + { + UserId = user.Id, Method = MfaMethodNames.Parse(method), Purpose = MfaEvidencePurpose.StepUp, Successful = false, + ClientApplication = (UserSessionClientApplication)(session?.ClientApplication ?? (int)UserSessionClientApplication.Api), + SharedMode = session?.SharedMode == true, DepartmentId = DepartmentId, SessionId = session?.SessionId + }, cancellationToken); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PasskeysController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PasskeysController.cs new file mode 100644 index 000000000..7ae228a69 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/PasskeysController.cs @@ -0,0 +1,210 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Passkey enrollment and the user's own inventory (passkey plan sections 6.1, 6.5 and 11). A passkey is registered for + /// the calling app only and works only there; the inventory covers every app, so a passkey made in one app can be + /// removed from any other. Every command rechecks the rollout gate and the session's server-side evidence. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] + public class PasskeysController : V4AuthenticatedApiControllerbase + { + private readonly UserManager _userManager; + private readonly IPasskeyService _passkeys; + + public PasskeysController(UserManager userManager, IPasskeyService passkeys) + { + _userManager = userManager; + _passkeys = passkeys; + } + + /// + /// Creation options for a new passkey bound to this app. Needs a password or SSO verification and a second factor + /// for this session within five minutes, and an authenticator app with recovery codes already set up. + /// + [HttpPost("RegistrationOptions")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RegistrationOptions(CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "session_revoked", title: "User not found.", statusCode: StatusCodes.Status401Unauthorized); + + var start = await _passkeys.BeginRegistrationAsync(ApiPasskeys.Caller(HttpContext, user.Id, user.UserName, DepartmentId), + await _userManager.GetTwoFactorEnabledAsync(user), await _userManager.CountRecoveryCodesAsync(user), cancellationToken); + if (!start.Succeeded) + return Failure(start.Outcome); + + return Ceremony(start); + } + + /// Verifies the platform's response and stores the passkey. The key is not usable until this returns. + [HttpPost("CompleteRegistration")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteRegistration([FromBody] CompletePasskeyRegistrationInput input, + CancellationToken cancellationToken) + { + if (input == null) + return Failure(PasskeyOutcome.InvalidRequest); + + var registered = await _passkeys.CompleteRegistrationAsync(ApiPasskeys.Caller(HttpContext, UserId, UserName, DepartmentId), + input.RequestId, ApiPasskeys.CredentialJson(input.Credential), input.DisplayName, cancellationToken); + if (!registered.Succeeded) + return Failure(registered.Outcome); + + var result = new PasskeyResult { Data = ToData(registered.Passkey), PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// The caller's own active passkeys in every app. Never another user's. + [HttpGet("List")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> List(CancellationToken cancellationToken) + { + var passkeys = await _passkeys.GetActiveForUserAsync(UserId, cancellationToken); + var result = new PasskeyListResult + { + Data = passkeys.Select(ToData).ToList(), + PageSize = passkeys.Count, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + [HttpPost("Rename")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Rename([FromBody] RenamePasskeyInput input, CancellationToken cancellationToken) + { + if (input == null) + return Failure(PasskeyOutcome.InvalidRequest); + + return Change(await _passkeys.RenameAsync(ApiPasskeys.Caller(HttpContext, UserId, UserName, DepartmentId), input.PasskeyId, + input.DisplayName, cancellationToken)); + } + + /// + /// Removes one passkey at Resgrid, in any app, after a second factor within five minutes (authenticator app, or a + /// passkey for this app). It may remain in the device or password manager but can no longer be used. Sessions that + /// signed in with it end, possibly including this one (CurrentSessionEnded). + /// + [HttpPost("Revoke")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Revoke([FromBody] RevokePasskeyInput input, CancellationToken cancellationToken) + { + if (input == null) + return Failure(PasskeyOutcome.InvalidRequest); + + return Change(await _passkeys.RevokeAsync(ApiPasskeys.Caller(HttpContext, UserId, UserName, DepartmentId), input.PasskeyId, cancellationToken)); + } + + /// Removes every passkey the caller has for one app, under the same verification rule as . + [HttpPost("RevokeAllForClient")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RevokeAllForClient([FromBody] RevokeAllPasskeysForClientInput input, + CancellationToken cancellationToken) + { + var client = ApiPasskeys.ClientFromName(input?.Client); + if (client == null) + return Failure(PasskeyOutcome.InvalidRequest); + + return Change(await _passkeys.RevokeAllForClientAsync(ApiPasskeys.Caller(HttpContext, UserId, UserName, DepartmentId), client.Value, + cancellationToken)); + } + + /// Responder passkeys only: allow or stop approving other apps' requests. + [HttpPost("SetApproval")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SetApproval([FromBody] SetPasskeyApprovalInput input, CancellationToken cancellationToken) + { + if (input == null) + return Failure(PasskeyOutcome.InvalidRequest); + + return Change(await _passkeys.SetApprovalEnabledAsync(ApiPasskeys.Caller(HttpContext, UserId, UserName, DepartmentId), input.PasskeyId, + input.Enabled, cancellationToken)); + } + + internal static PasskeyResultData ToData(UserPasskey passkey) => new() + { + PasskeyId = passkey.UserPasskeyId, + DisplayName = passkey.DisplayName, + Client = ApiPasskeys.ClientName((UserSessionClientApplication)passkey.ClientApplication), + CreatedOn = ApiPasskeys.Iso(passkey.CreatedOnUtc), + CreatedPlatform = passkey.RegistrationPlatform, + CreatedInstallation = passkey.RegistrationInstallation, + CreatedUserAgentFamily = passkey.RegistrationUserAgentFamily, + CreatedOnSharedInstallation = passkey.RegisteredInSharedMode, + Attachment = passkey.RegistrationAttachment, + BackupEligible = passkey.IsBackupEligible, + BackedUp = passkey.IsBackedUp, + LastUsedOn = ApiPasskeys.Iso(passkey.LastUsedOnUtc), + LastUsedClient = passkey.LastUsedClientApplication == null + ? null + : ApiPasskeys.ClientName((UserSessionClientApplication)passkey.LastUsedClientApplication.Value), + LastUsedInstallation = passkey.LastUsedInstallation, + ApprovalEnabled = passkey.ClientApplication == (int)UserSessionClientApplication.Responder ? passkey.ApprovalEnabled : null + }; + + private ActionResult Ceremony(PasskeyCeremonyStart start) + { + var result = new PasskeyCeremonyResult + { + Data = new PasskeyCeremonyResultData { RequestId = start.RequestId, Options = ApiPasskeys.Options(start.OptionsJson) }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ActionResult Change(PasskeyOutcome outcome) => Change(PasskeyRevocationResult.Of(outcome)); + + private ActionResult Change(PasskeyRevocationResult change) + { + if (change.Outcome != PasskeyOutcome.Succeeded) + return Failure(change.Outcome); + + var result = new PasskeyChangeResult + { + Data = new PasskeyChangeResultData + { + Revoked = change.Revoked, + SessionsEnded = change.SessionsEnded, + CurrentSessionEnded = change.CurrentSessionEnded + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ObjectResult Failure(PasskeyOutcome outcome) => + Problem(type: PasskeyOutcomes.ErrorCode(outcome), title: ApiPasskeys.TitleFor(outcome), statusCode: ApiPasskeys.StatusFor(outcome)); + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ProtectedWorkflowsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ProtectedWorkflowsController.cs index 30184505b..91638f169 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ProtectedWorkflowsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ProtectedWorkflowsController.cs @@ -27,10 +27,12 @@ public class ProtectedWorkflowsController : V4AuthenticatedApiControllerbase private readonly IProtectedWorkflowService _protectedWorkflows; private readonly IDepartmentDataProtectionService _dataProtectionService; private readonly IProtectedDataGrantService _grantService; + private readonly IMfaCredentialStateService _credentialStates; public ProtectedWorkflowsController(IProtectedWorkflowService protectedWorkflows, IDepartmentDataProtectionService dataProtectionService, - IProtectedDataGrantService grantService) + IProtectedDataGrantService grantService, IMfaCredentialStateService credentialStates) { + _credentialStates = credentialStates; _protectedWorkflows = protectedWorkflows; _dataProtectionService = dataProtectionService; _grantService = grantService; @@ -247,7 +249,8 @@ private async Task ActorAsync() var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId, bypassCache: true); var outcome = _grantService.ValidateGrant(token, DepartmentId, policy?.PolicyEpoch ?? 0, requiredScope: null, out var grant); if (outcome == ProtectedDataGrantValidationOutcome.Valid && grant != null && !grant.StepUpExempt && - string.Equals(grant.UserId, UserId, StringComparison.OrdinalIgnoreCase)) + await Resgrid.Services.ProtectedGrantBinding.CheckAsync(grant, UserId, HttpProtectedGrantContext.SessionOf(HttpContext), + policy?.StepUpWindowMinutes, _credentialStates) == Resgrid.Model.Security.ProtectedGrantBindingOutcome.Bound) stepUpAt = grant.MfaAtUtc; } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs index 64dadcb63..3d2a05327 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SearchController.cs @@ -57,14 +57,19 @@ public SearchController(IUnifiedSearchService unifiedSearch, IGlobalSearchServic /// /// Full search. is a comma-separated list of entity types (Call, Unit, Personnel, - /// Contact, Message, Document, Note, Record, Action); omit for all. + /// Contact, Message, Document, Note, Record, Log, Occupancy, Protocol, Training, CalendarEvent, Poi, Shift, Group, the + /// Business Operations families, Action); omit for all. Words must all match; a "quoted phrase" matches as a + /// phrase. / bound when the hit occurred, is + /// relevance (default), newest or oldest. A single family or a date range is authorized over a deeper window so its + /// count is exact more often. /// [HttpGet("Search")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = ResgridResources.Department_View)] - public async Task> Search(string query, string types = null, int skip = 0, int take = 20, CancellationToken cancellationToken = default) + public async Task> Search(string query, string types = null, int skip = 0, int take = 20, DateTime? fromUtc = null, + DateTime? toUtc = null, string sort = null, CancellationToken cancellationToken = default) { if (!await _featureToggles.IsEnabledAsync(FeatureFlagKeys.SearchUnified, DepartmentId)) return NotFound(); @@ -75,6 +80,10 @@ public async Task> Search(string query, string types // The page metadata must describe the query that ran, so the clamped values feed both. var effectiveSkip = Math.Max(0, skip); var effectiveTake = Math.Max(1, Math.Min(MaxTake, take)); + var from = AsUtc(fromUtc); + var to = AsUtc(toUtc); + var narrowed = requestedTypes != null && requestedTypes.Count(t => !string.Equals(t, SearchEntityTypes.Action, StringComparison.OrdinalIgnoreCase)) == 1 + || from.HasValue || to.HasValue; var unified = await _unifiedSearch.SearchAsync(new UnifiedSearchRequest { Text = query, @@ -83,7 +92,11 @@ public async Task> Search(string query, string types Take = effectiveTake, IncludeActions = requestedTypes == null || requestedTypes.Any(t => string.Equals(t, SearchEntityTypes.Action, StringComparison.OrdinalIgnoreCase)), IncludeRecords = requestedTypes == null || requestedTypes.Any(t => string.Equals(t, SearchEntityTypes.Record, StringComparison.OrdinalIgnoreCase)), - Prefix = false + Prefix = false, + FromUtc = from, + ToUtc = to, + Sort = SearchSortOrders.Normalize(sort), + MaxCandidates = narrowed ? Config.SearchConfig.MaxPageWindow : 0 }, await BuildPrincipalAsync(), cancellationToken); return Ok(Map(unified, effectiveSkip, effectiveTake)); @@ -186,6 +199,19 @@ public async Task> Health() return Ok(result); } + /// Query-string instants bind as local or unspecified unless they carry an offset; treat a bare value as UTC. + private static DateTime? AsUtc(DateTime? value) + { + if (!value.HasValue) + return null; + return value.Value.Kind switch + { + DateTimeKind.Utc => value.Value, + DateTimeKind.Local => value.Value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) + }; + } + private static List ParseTypes(string types) { if (string.IsNullOrWhiteSpace(types)) @@ -239,6 +265,7 @@ private static SearchResult Map(UnifiedSearchResult unified, int skip, int take) Available = unified.Available, Degraded = unified.Degraded, DegradedReason = unified.DegradedReason, + IndexBuilding = unified.IndexBuilding, TotalCount = unified.Total, Truncated = unified.Truncated, QueryTimeMs = unified.QueryTimeMs, @@ -253,6 +280,7 @@ private static SearchResult Map(UnifiedSearchResult unified, int skip, int take) OccurredOn = h.OccurredOn, Category = h.Category, Status = h.Status, + Snippet = h.Snippet, Metadata = h.Metadata == null ? new Dictionary() : new Dictionary(h.Metadata) }).ToList(), Actions = unified.Actions.Select(a => new SearchActionData diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs index cc9ea49c6..dd1c99cfd 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SessionsController.cs @@ -1,30 +1,66 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; using Resgrid.Model; +using Resgrid.Model.Providers; using Resgrid.Model.Security; using Resgrid.Model.Services; using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.MfaApproval; +using Resgrid.Web.Services.Models.v4.Passkeys; +using Resgrid.Web.Services.Models.v4.Sessions; namespace Resgrid.Web.Services.Controllers.v4 { - /// User authentication session inventory and revocation. + /// + /// User authentication session inventory and revocation, and the shared vehicle and workstation session lifecycle (passkey + /// plan sections 11 and 12.5): status, lock, unlock and end shift. Those are the only endpoints a locked shared session can + /// reach; session validation refuses it everywhere else. + /// [Route("api/v{VersionId:apiVersion}/sessions")] [ApiVersion("4.0")] [ApiExplorerSettings(GroupName = "v4")] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] public class SessionsController : V4AuthenticatedApiControllerbase { + private static readonly TimeSpan UnlockAttemptWindow = TimeSpan.FromMinutes(5); + private readonly IUserSessionService _userSessionService; private readonly ISystemAuditsService _systemAuditsService; + private readonly ISharedSessionService _sharedSessions; + private readonly UserManager _userManager; + private readonly IMfaPolicyService _mfaPolicy; + private readonly IPasskeyService _passkeys; + private readonly IMfaApprovalService _approvals; + private readonly ICacheProvider _cacheProvider; + private readonly ISsoBrokerService _ssoBroker; + private readonly IDepartmentSsoService _departmentSso; + private readonly IDepartmentsService _departments; - public SessionsController(IUserSessionService userSessionService, ISystemAuditsService systemAuditsService) + public SessionsController(IUserSessionService userSessionService, ISystemAuditsService systemAuditsService, ISharedSessionService sharedSessions, + UserManager userManager, IMfaPolicyService mfaPolicy, IPasskeyService passkeys, IMfaApprovalService approvals, + ICacheProvider cacheProvider, ISsoBrokerService ssoBroker, IDepartmentSsoService departmentSso, IDepartmentsService departments) { + _ssoBroker = ssoBroker; + _departmentSso = departmentSso; + _departments = departments; _userSessionService = userSessionService; _systemAuditsService = systemAuditsService; + _sharedSessions = sharedSessions; + _userManager = userManager; + _mfaPolicy = mfaPolicy; + _passkeys = passkeys; + _approvals = approvals; + _cacheProvider = cacheProvider; } [HttpGet] @@ -73,6 +109,505 @@ public async Task RevokeAll(CancellationToken cancellationToken) return Ok(new { revoked = result.RevokedSessionCount, reauthenticationRequired = true }); } + // ── Shared vehicle and workstation sessions (plan section 12.5.3) ────────────── + + /// This session's operator, lock state and deadlines. Available while locked. + [HttpGet("current")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Current(CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return SessionRequired(); + + return Wrap(new CurrentSessionResult { Data = ToData(await _sharedSessions.GetStatusAsync(session, cancellationToken), session) }); + } + + /// + /// Locks this shared session now (Lock, an OS lock, or the app going to the background). Everything issued before it, + /// including Protected Data Grants and pending verifications, stops working. Locking a locked session is fine. + /// + [HttpPost("lock")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Lock(CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return SessionRequired(); + + var locked = await _sharedSessions.LockAsync(session, RequestInfo(), cancellationToken); + if (!locked.Succeeded) + return Refuse(locked.Outcome); + + return Wrap(new SessionLockResult { Data = new SessionLockResultData { Locked = true, LockVersion = locked.LockVersion } }); + } + + /// + /// End shift or Switch operator: ends this shared session. The client then clears the operator's tokens, caches and + /// connections and signs the next operator in normally. Available while locked. + /// + [HttpPost("end-shift")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> EndShift([FromBody] EndShiftInput input, CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return SessionRequired(); + + var ended = await _sharedSessions.EndShiftAsync(session, input?.SwitchOperator == true, RequestInfo(), cancellationToken); + if (!ended.Succeeded) + return Refuse(ended.Outcome); + + return Wrap(new EndShiftResult { Data = new EndShiftResultData { Ended = true } }); + } + + /// + /// How the locked session's own operator can unlock it, bound to its current lock version. Empty methods mean quick + /// unlock is unavailable (no factor, or the department's rules exclude them): end the shift and sign in normally. + /// + [HttpPost("unlock-options")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> UnlockOptions([FromBody] UnlockOptionsInput input, CancellationToken cancellationToken) + { + var (session, user, refusal) = await OpenUnlockAsync(input?.LockVersion, cancellationToken); + if (refusal != null) + return refusal; + + PasskeyCeremonyResultData passkey = null; + var client = (UserSessionClientApplication)session.ClientApplication; + + // Passkeys and approval needed the authenticator app to register, so they count only with it. The identity + // provider's MFA proves the provider account instead, so an SSO operator without one can still use it. + var totpEnrolled = await _userManager.GetTwoFactorEnabledAsync(user); + var passkeyEnrolled = totpEnrolled && await _passkeys.HasActiveForClientAsync(user.Id, client, cancellationToken); + var approvalEnrolled = totpEnrolled && await _approvals.IsAvailableAsync(user.Id, client, cancellationToken); + var federatedEnrolled = session.DepartmentId is int departmentId && await _departmentSso.IsFederatedMfaAvailableAsync(departmentId, user.Id, cancellationToken); + var choice = await _mfaPolicy.GetMethodChoiceAsync(user.Id, totpEnrolled, session.DepartmentId, MfaMethodScope.Login, passkeyEnrolled, + federatedEnrolled, approvalEnrolled, cancellationToken); + var methods = choice.AllowedMethods.Where(choice.EnrolledMethods.Contains).Where(UnlockMethods.Contains).ToList(); + // The client shows this first but never starts the passkey prompt on its own on a shared installation. + var preferred = methods.Contains(choice.Preferred) ? choice.Preferred : methods.FirstOrDefault(); + + if (methods.Contains(MfaMethodNames.Passkey)) + { + // Advisory, like the rest of this response: when the ceremony cannot start, the other methods still work. + var start = await _passkeys.BeginAssertionAsync(UnlockCaller(session, user), AuthenticationChallengePurpose.SharedDeviceUnlock, + cancellationToken); + if (start.Succeeded) + passkey = new PasskeyCeremonyResultData { RequestId = start.RequestId, Options = ApiPasskeys.Options(start.OptionsJson) }; + } + + return Wrap(new UnlockOptionsResult + { + Data = new UnlockOptionsResultData + { + Operator = user.UserName, + LockVersion = session.LockVersion, + Methods = methods, + Preferred = preferred, + Passkey = passkey + } + }); + } + + /// + /// Asks the operator's own Responder to approve this unlock (plan section 7.9). The request is bound to this lock + /// version; show the number on this screen only, then poll unlock-approval/{id}. + /// + [HttpPost("unlock-approval")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> RequestUnlockApproval([FromBody] UnlockApprovalInput input, CancellationToken cancellationToken) + { + var (session, user, refusal) = await OpenUnlockAsync(input?.LockVersion, cancellationToken); + if (refusal != null) + return refusal; + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return RefuseApproval(MfaApprovalOutcome.Unavailable); + + var start = await _approvals.RequestAsync(new MfaApprovalRequester + { + UserId = user.Id, + Kind = MfaApprovalRequesterKind.Session, + RequesterId = session.UserSessionId, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + AuthenticationGeneration = session.AuthenticationGeneration, + DepartmentId = session.DepartmentId, + Purpose = MfaApprovalPurpose.Unlock, + SharedMode = true, + LockVersion = session.LockVersion, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + UserName = user.UserName, + AuditSystem = SystemAuditSystems.Api + }, cancellationToken); + if (!start.Succeeded) + return RefuseApproval(start.Outcome); + + return Wrap(new MfaApprovalRequestResult + { + Data = new MfaApprovalRequestResultData { ApprovalRequestId = start.ApprovalRequestId, MatchNumber = start.MatchNumber, ExpiresIn = start.ExpiresInSeconds } + }); + } + + /// + /// Begins an unlock through the department's identity provider, which must perform MFA the department's tested mapping + /// accepts (plan section 7.8). The provider is asked to let the operator choose the account. The transaction is bound + /// to this session and counts only for the lock it began in; finish with complete-unlock method federated. + /// + [HttpPost("unlock-sso")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> BeginUnlockSso([FromBody] UnlockSsoInput input, CancellationToken cancellationToken) + { + var (session, user, refusal) = await OpenUnlockAsync(input?.LockVersion, cancellationToken); + if (refusal != null) + return refusal; + if (session.DepartmentId is not int departmentId || + !await _mfaPolicy.IsMethodAcceptedAsync(departmentId, MfaMethodScope.Login, MfaEvidenceMethod.Federated, cancellationToken) || + !await _departmentSso.IsFederatedMfaAvailableAsync(departmentId, user.Id, cancellationToken)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest); + + var department = await _departments.GetDepartmentByIdAsync(departmentId); + var begun = await _ssoBroker.BeginAsync(new SsoBeginRequest + { + DepartmentId = departmentId, + DepartmentCode = department?.Code, + Purpose = SsoTransactionPurpose.StepUp, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + Platform = input.Platform, + ReturnTarget = input.ReturnTarget, + ClientState = input.State, + CodeChallenge = input.CodeChallenge, + CodeChallengeMethod = input.CodeChallengeMethod, + SessionId = session.UserSessionId, + UserId = user.Id, + AuthenticationGeneration = session.AuthenticationGeneration, + Operation = SsoLoginTransaction.SharedUnlockOperation, + SharedInstallation = true + }, cancellationToken); + if (!begun.Succeeded) + return Problem(type: SsoBrokerOutcomes.ErrorCode(begun.Outcome) ?? "sso_failed", title: "The sign-in with your identity provider could not start.", + statusCode: begun.Outcome == SsoBrokerOutcome.ServiceUnavailable ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status400BadRequest); + + return Wrap(new Models.v4.Sso.SsoBeginResult + { + Data = new Models.v4.Sso.SsoBeginResultData { AuthorizeUrl = begun.AuthorizeUrl, SsoTransactionId = begun.TransactionId, ExpiresIn = begun.ExpiresInSeconds } + }); + } + + /// The unlock approval's state, for this session only. Poll every 2 seconds. + [HttpGet("unlock-approval/{approvalRequestId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UnlockApprovalStatus(string approvalRequestId, CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return SessionRequired(); + + var found = await _approvals.GetForRequesterAsync(approvalRequestId, MfaApprovalRequesterKind.Session, session.UserSessionId, cancellationToken); + if (!found.Succeeded || found.Request.RequestPurpose != MfaApprovalPurpose.Unlock) + return RefuseApproval(found.Succeeded ? MfaApprovalOutcome.NotFound : found.Outcome); + + return Wrap(new MfaApprovalStatusResult + { + Data = new MfaApprovalStatusResultData + { + // A lock since the request voids it, whatever Responder does with it. + State = MfaApprovalOutcomes.StateName(found.Request.LockVersion == session.LockVersion + ? found.Request.EffectiveState(DateTime.UtcNow) + : MfaApprovalRequestState.Canceled), + ExpiresAt = found.Request.ExpiresOnUtc.ToString("O") + } + }); + } + + /// Cancels this session's unlock approval request. + [HttpDelete("unlock-approval/{approvalRequestId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CancelUnlockApproval(string approvalRequestId, CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return SessionRequired(); + + var outcome = await _approvals.CancelAsync(approvalRequestId, MfaApprovalRequesterKind.Session, session.UserSessionId, cancellationToken); + if (outcome != MfaApprovalOutcome.Succeeded) + return RefuseApproval(outcome); + + return Wrap(new MfaApprovalStatusResult { Data = new MfaApprovalStatusResultData { State = MfaApprovalOutcomes.StateName(MfaApprovalRequestState.Canceled) } }); + } + + /// + /// Unlocks this locked shared session for its own operator with a TOTP code, a passkey for this app, or an approved + /// Responder request, all at the lock version from unlock-options. The same session resumes: the first-factor + /// time and the shift end do not change, and nothing from before the lock works again. + /// + [HttpPost("complete-unlock")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + public async Task> CompleteUnlock([FromBody] CompleteUnlockInput input, CancellationToken cancellationToken) + { + var method = input?.Method ?? MfaMethodNames.Totp; + if (input == null || !UnlockMethods.Contains(method)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest); + if (method == MfaMethodNames.Totp && string.IsNullOrWhiteSpace(input.Code)) + return Problem(type: "invalid_totp", title: "A verification code is required.", statusCode: StatusCodes.Status400BadRequest); + + var (session, user, refusal) = await OpenUnlockAsync(input.LockVersion, cancellationToken); + if (refusal != null) + return refusal; + + // Per session, on top of the account lockout; it fails open on cache faults, the lockout does not. + if (await _cacheProvider.IncrementAsync($"SharedUnlockAttempts_{session.UserSessionId}", UnlockAttemptWindow) > + Math.Max(1, PasskeyConfig.SharedUnlockMaxAttempts)) + return Problem(type: "too_many_attempts", title: "Too many unlock attempts. Wait a few minutes or end the shift and sign in again.", + statusCode: StatusCodes.Status429TooManyRequests); + + // The identity provider's MFA proves the provider account, so it needs no Resgrid factor; everything else does. + if (method != MfaMethodNames.Federated && !await _userManager.GetTwoFactorEnabledAsync(user)) + return Problem(type: "mfa_enrollment_required", title: "Quick unlock needs an authenticator app. End the shift and sign in again.", + statusCode: StatusCodes.Status409Conflict); + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + DateTime verifiedAt; + MfaEvidenceMethod evidenceMethod; + string factorReference = null; + if (method == MfaMethodNames.Federated) + { + var federated = await VerifyFederatedUnlockAsync(session, user, input, cancellationToken); + if (federated.Problem != null) + return federated.Problem; + + verifiedAt = federated.VerifiedAt; + evidenceMethod = MfaEvidenceMethod.Federated; + factorReference = federated.FactorReference; + } + else if (method == MfaMethodNames.Passkey) + { + if (!await _mfaPolicy.IsMethodAcceptedAsync(session.DepartmentId, MfaMethodScope.Login, MfaEvidenceMethod.Passkey, cancellationToken)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest); + + var assertion = await _passkeys.CompleteAssertionAsync(UnlockCaller(session, user), AuthenticationChallengePurpose.SharedDeviceUnlock, + input.RequestId, ApiPasskeys.CredentialJson(input.Credential), cancellationToken); + if (!assertion.Succeeded) + { + if (assertion.Outcome is PasskeyOutcome.VerificationFailed or PasskeyOutcome.NotRegisteredForClient) + await _sharedSessions.RecordFailedUnlockAsync(session, method, RequestInfo(user), cancellationToken); + return Problem(type: PasskeyOutcomes.ErrorCode(assertion.Outcome), title: ApiPasskeys.TitleFor(assertion.Outcome), + statusCode: ApiPasskeys.StatusFor(assertion.Outcome)); + } + + verifiedAt = assertion.VerifiedOnUtc; + evidenceMethod = MfaEvidenceMethod.Passkey; + factorReference = UserPasskey.FactorReferenceFor(assertion.Passkey.UserPasskeyId); + } + else if (method == MfaMethodNames.PasskeyApproval) + { + if (!await _mfaPolicy.IsMethodAcceptedAsync(session.DepartmentId, MfaMethodScope.Login, MfaEvidenceMethod.PasskeyApproval, cancellationToken)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest); + + // Only an unlock request made by this session at this lock version unlocks it. + var found = await _approvals.GetForRequesterAsync(input.ApprovalRequestId, MfaApprovalRequesterKind.Session, session.UserSessionId, + cancellationToken); + if (found.Succeeded && (found.Request.RequestPurpose != MfaApprovalPurpose.Unlock || found.Request.LockVersion != session.LockVersion)) + return Problem(type: "approval_expired", title: "That approval was for an earlier lock. Request it again.", + statusCode: StatusCodes.Status400BadRequest); + + var consumed = await _approvals.ConsumeAsync(input.ApprovalRequestId, MfaApprovalRequesterKind.Session, session.UserSessionId, user.Id, + session.AuthenticationGeneration, cancellationToken); + if (!consumed.Succeeded) + { + if (consumed.Outcome == MfaApprovalOutcome.Denied) + await _sharedSessions.RecordFailedUnlockAsync(session, method, RequestInfo(user), cancellationToken); + return RefuseApproval(consumed.Outcome); + } + + verifiedAt = consumed.Request.DecidedOnUtc ?? DateTime.UtcNow; + evidenceMethod = MfaEvidenceMethod.PasskeyApproval; + factorReference = MfaApprovalRequest.FactorReferenceFor(consumed.Request.ApproverPasskeyId, consumed.Request.ApproverSessionId); + } + else + { + // One-time: each time step is accepted once per user, so a code seen on this screen cannot be replayed. + if (!await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, + input.Code.Replace(" ", string.Empty).Replace("-", string.Empty))) + { + await _userManager.AccessFailedAsync(user); + await _sharedSessions.RecordFailedUnlockAsync(session, method, RequestInfo(user), cancellationToken); + return Problem(type: "invalid_totp", title: "The verification code is invalid or has expired.", statusCode: StatusCodes.Status401Unauthorized); + } + + await _userManager.ResetAccessFailedCountAsync(user); + verifiedAt = DateTime.UtcNow; + evidenceMethod = MfaEvidenceMethod.Totp; + } + + var unlocked = await _sharedSessions.UnlockAsync(session, input.LockVersion, evidenceMethod, factorReference, verifiedAt, RequestInfo(user), + cancellationToken); + if (!unlocked.Succeeded) + return Refuse(unlocked.Outcome); + + return Wrap(new CompleteUnlockResult { Data = ToData(await _sharedSessions.GetStatusAsync(session, cancellationToken), session, user.UserName) }); + } + + private static readonly string[] UnlockMethods = + { MfaMethodNames.Totp, MfaMethodNames.Passkey, MfaMethodNames.PasskeyApproval, MfaMethodNames.Federated }; + + /// + /// Redeems a provider step-up begun with unlock-sso for this session and lock: the same department and tested + /// mapping, this session and operator, the current generation, and begun after the session's last lock, so a round + /// trip from before a lock or for anything else never unlocks it. Evidence names the SSO configuration and mapping + /// version, so a mapping change retires it. + /// + private async Task<(ObjectResult Problem, DateTime VerifiedAt, string FactorReference)> VerifyFederatedUnlockAsync(UserSession session, + Model.Identity.IdentityUser user, CompleteUnlockInput input, CancellationToken cancellationToken) + { + if (session.DepartmentId is not int departmentId || + !await _mfaPolicy.IsMethodAcceptedAsync(departmentId, MfaMethodScope.Login, MfaEvidenceMethod.Federated, cancellationToken)) + return (Problem(type: "mfa_method_not_allowed", title: "That verification method is not available.", statusCode: StatusCodes.Status400BadRequest), + default, null); + + var redeemed = await _ssoBroker.RedeemAsync(input.SsoTransactionId, input.SsoCode, input.CodeVerifier, + (UserSessionClientApplication)session.ClientApplication, cancellationToken, SsoTransactionPurpose.StepUp); + var transaction = redeemed.Transaction; + var config = redeemed.Succeeded ? await _departmentSso.GetTestedFederatedMfaConfigAsync(departmentId, cancellationToken) : null; + if (!redeemed.Succeeded || !SharedSessionRules.FederatedUnlockMatches(transaction, session, user.Id, departmentId, config)) + { + if (redeemed.Succeeded) + await _sharedSessions.RecordFailedUnlockAsync(session, MfaMethodNames.Federated, RequestInfo(user), cancellationToken); + return (Problem(type: redeemed.Succeeded ? "federated_mfa_not_satisfied" : SsoBrokerOutcomes.ErrorCode(redeemed.Outcome) ?? "sso_failed", + title: "The identity provider sign-in could not unlock this session. Start it again.", statusCode: StatusCodes.Status401Unauthorized), + default, null); + } + + return (null, transaction.AuthenticatedOnUtc ?? DateTime.UtcNow, + FederatedMfaMapping.FactorReferenceFor(config.DepartmentSsoConfigId, config.FederatedMfaMappingVersion)); + } + + /// + /// The locked session this request is about, its operator, and whether they may try to unlock it now: shared, locked, + /// still at the lock version the client saw, and not waiting on an SSO sign-in. + /// + private async Task<(UserSession Session, Model.Identity.IdentityUser User, ObjectResult Refusal)> OpenUnlockAsync(long? expectedLockVersion, + CancellationToken cancellationToken) + { + var session = OwnSession(); + if (session == null) + return (null, null, SessionRequired()); + + var outcome = await _sharedSessions.CanUnlockAsync(session, cancellationToken); + if (outcome != SharedSessionOutcome.Succeeded) + return (null, null, Refuse(outcome)); + if (expectedLockVersion != session.LockVersion) + return (null, null, Refuse(SharedSessionOutcome.LockChanged)); + + var user = await _userManager.FindByIdAsync(session.UserId); + return user == null ? (null, null, Refuse(SharedSessionOutcome.SessionEnded)) : (session, user, null); + } + + /// The session request validation accepted for this caller; for a locked session, only on these endpoints. + private UserSession OwnSession() + { + var session = SharedSessionEndpoints.SessionOf(HttpContext); + return session != null && string.Equals(session.UserId, UserId, StringComparison.OrdinalIgnoreCase) ? session : null; + } + + private PasskeyCaller UnlockCaller(UserSession session, Model.Identity.IdentityUser user) => new() + { + UserId = user.Id, + UserName = user.UserName, + SessionId = session.UserSessionId, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + AuthenticationGeneration = session.AuthenticationGeneration, + SessionLockVersion = session.LockVersion, + DepartmentId = session.DepartmentId, + SharedMode = true, + AuditSystem = SystemAuditSystems.Api, + IpAddress = IpAddressHelper.GetRequestIP(Request, true) + }; + + private SharedSessionRequestInfo RequestInfo(Model.Identity.IdentityUser user = null) => new() + { + UserName = user?.UserName ?? UserName, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + CorrelationId = HttpContext.TraceIdentifier + }; + + private static CurrentSessionResultData ToData(SharedSessionStatus status, UserSession session, string operatorName = null) => new() + { + Operator = operatorName, + Client = ApiPasskeys.ClientName((UserSessionClientApplication)session.ClientApplication), + Shared = status.Shared, + Locked = status.Locked, + LockVersion = status.LockVersion, + LockReason = status.LockReason switch + { + SharedSessionLockReason.Explicit => "explicit", + SharedSessionLockReason.Idle => "idle", + _ => null + }, + IdleLockMinutes = status.IdleLockMinutes, + IdleLocksAt = ApiPasskeys.Iso(status.IdleLocksOnUtc), + ShiftEndsAt = ApiPasskeys.Iso(status.ShiftEndsOnUtc), + InstallationLabel = status.InstallationLabel + }; + + private ObjectResult SessionRequired() => + Problem(type: "session_required", title: "Sign in again to continue.", statusCode: StatusCodes.Status409Conflict); + + private ObjectResult Refuse(SharedSessionOutcome outcome) => Problem( + type: SharedSessionOutcomes.ErrorCode(outcome), + title: outcome switch + { + SharedSessionOutcome.NotShared => "This is not a shared session.", + SharedSessionOutcome.NotLocked => "This session is not locked.", + SharedSessionOutcome.LockChanged => "The session locked again. Start the unlock again.", + SharedSessionOutcome.SsoReauthenticationRequired => "Your department requires single sign-on. End the shift and sign in with SSO.", + SharedSessionOutcome.SessionEnded => "This session has ended. Sign in again.", + _ => "The session could not be updated. Try again." + }, + statusCode: outcome switch + { + SharedSessionOutcome.SessionEnded or SharedSessionOutcome.SsoReauthenticationRequired => StatusCodes.Status401Unauthorized, + SharedSessionOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status409Conflict + }); + + private ObjectResult RefuseApproval(MfaApprovalOutcome outcome) => Problem( + type: MfaApprovalOutcomes.ErrorCode(outcome) ?? "approval_unavailable", + title: outcome switch + { + MfaApprovalOutcome.Pending => "The request has not been approved yet.", + MfaApprovalOutcome.Denied => "The request was denied in Responder.", + MfaApprovalOutcome.Suspended => "Approval requests are paused for a few minutes. Use another verification method.", + MfaApprovalOutcome.TooManyRequests => "Too many approval requests. Wait a few minutes or use another verification method.", + MfaApprovalOutcome.Unavailable => "Approve with Responder is not available here.", + _ => "The request is no longer valid. Start again." + }, + statusCode: outcome switch + { + MfaApprovalOutcome.Pending => StatusCodes.Status409Conflict, + MfaApprovalOutcome.Denied => StatusCodes.Status403Forbidden, + MfaApprovalOutcome.TooManyRequests or MfaApprovalOutcome.Suspended => StatusCodes.Status429TooManyRequests, + MfaApprovalOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + + private static T Wrap(T result) where T : Models.v4.StandardApiResponseV4Base + { + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + private Task AuditAsync(SystemAuditTypes type, string sessionId, bool successful, CancellationToken cancellationToken) { return _systemAuditsService.SaveSystemAuditAsync(new SystemAudit diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs index d7ff1c98d..2ea01e168 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/SsoAdminController.cs @@ -6,10 +6,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model; +using Resgrid.Model.Security; using Resgrid.Model.Services; using Resgrid.Providers.Claims; using Resgrid.Web.Services.Helpers; using Resgrid.Web.Services.Models.v4.Sso; +using SsoBeginResult = Resgrid.Web.Services.Models.v4.Sso.SsoBeginResult; namespace Resgrid.Web.Services.Controllers.v4 { @@ -30,6 +32,8 @@ public class SsoAdminController : V4AuthenticatedApiControllerbase private readonly IPermissionsService _permissionsService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaPolicyService _mfaPolicy; /// Constructor. public SsoAdminController( @@ -37,15 +41,48 @@ public SsoAdminController( IDepartmentsService departmentsService, IPermissionsService permissionsService, IDepartmentGroupsService departmentGroupsService, - IPersonnelRolesService personnelRolesService) + IPersonnelRolesService personnelRolesService, + IMfaEvidenceService mfaEvidence, + IMfaPolicyService mfaPolicy, + ISsoBrokerService ssoBroker, + ISystemAuditsService systemAuditsService, + IPasskeyFeatureGates passkeyGates) { + _passkeyGates = passkeyGates; + _ssoBroker = ssoBroker; + _systemAuditsService = systemAuditsService; + _mfaPolicy = mfaPolicy; _ssoService = ssoService; _departmentsService = departmentsService; _permissionsService = permissionsService; _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; + _mfaEvidence = mfaEvidence; } + /// + /// Security, SSO, SCIM and MFA policy changes need an actual second factor on this session within the last few + /// minutes (passkey plan section 7.6 row 13): call Mfa/VerifyStepUp with operation security_change, then + /// retry. Null when the change may proceed. + /// + private async Task RequireRecentMfaAsync(CancellationToken cancellationToken, bool excludeFederated = false) + { + var window = MfaStepUpOperations.WindowFor(MfaStepUpOperations.SecurityChange); + if (await ApiStepUpEvidence.HasRecentSecondFactorAsync(_mfaEvidence, _mfaPolicy, UserId, HttpContext, DepartmentId, + MfaMethodScope.SecurityChange, window, cancellationToken, excludeFederated)) + return null; + + return Problem(type: "step_up_required", + title: excludeFederated + ? $"Verify with your authenticator app or a passkey (Mfa/VerifyStepUp, operation {MfaStepUpOperations.SecurityChange}) within the last {(int)window.TotalMinutes} minutes; provider step-up cannot authorize changes to itself." + : $"Verify a second factor (Mfa/VerifyStepUp, operation {MfaStepUpOperations.SecurityChange}) within the last {(int)window.TotalMinutes} minutes, then retry.", + statusCode: StatusCodes.Status403Forbidden); + } + + private readonly ISsoBrokerService _ssoBroker; + private readonly IPasskeyFeatureGates _passkeyGates; + private readonly ISystemAuditsService _systemAuditsService; + // ── SSO Config — list / get ─────────────────────────────────────────── /// @@ -125,6 +162,8 @@ public async Task> CreateSsoConfig( { if (!ModelState.IsValid) return BadRequest(ModelState); if (!await IsAdminAsync()) return Forbid(); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken); + if (mfaProblem != null) return mfaProblem; if (!Enum.TryParse(input.ProviderType, ignoreCase: true, out var providerType) || !Enum.IsDefined(providerType)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); @@ -168,6 +207,8 @@ public async Task> UpdateSsoConfig( { if (!ModelState.IsValid) return BadRequest(ModelState); if (!await IsAdminAsync()) return Forbid(); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken); + if (mfaProblem != null) return mfaProblem; var configs = await _ssoService.GetSsoConfigsForDepartmentAsync(DepartmentId, cancellationToken); var config = configs.FirstOrDefault(c => c.DepartmentSsoConfigId == id); @@ -196,6 +237,7 @@ public async Task> UpdateSsoConfig( config.MetadataUrl = input.MetadataUrl ?? config.MetadataUrl; config.EntityId = input.EntityId ?? config.EntityId; config.AssertionConsumerServiceUrl = input.AssertionConsumerServiceUrl ?? config.AssertionConsumerServiceUrl; + config.IdpSsoUrl = input.IdpSsoUrl ?? config.IdpSsoUrl; config.AttributeMappingJson = input.AttributeMappingJson ?? config.AttributeMappingJson; config.AllowLocalLogin = input.AllowLocalLogin; config.AutoProvisionUsers = input.AutoProvisionUsers; @@ -244,6 +286,8 @@ public async Task> DeleteSsoConfig( CancellationToken cancellationToken) { if (!await IsAdminAsync()) return Forbid(); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken); + if (mfaProblem != null) return mfaProblem; if (!Enum.TryParse(providerType, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); @@ -283,6 +327,8 @@ public async Task> RotateScimToken( CancellationToken cancellationToken) { if (!await IsAdminAsync()) return Forbid(); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken); + if (mfaProblem != null) return mfaProblem; if (!Enum.TryParse(providerType, ignoreCase: true, out var provider) || !Enum.IsDefined(provider)) return BadRequest(new { error = "Invalid providerType. Must be 'saml2' or 'oidc'." }); @@ -341,11 +387,11 @@ public async Task> GetSecurityPolicy(Cance ResponseHelper.PopulateV4ResponseData(result); result.Status = ResponseHelper.Success; result.PageSize = 1; - result.Data = policy != null ? MapToSecurityPolicyData(policy) : new SecurityPolicyData - { - CreatedOn = DateTime.UtcNow, - MinPasswordLength = 8 - }; + // No row behaves as the defaults (passkey plan section 10.1), so report them rather than all-false switches. + var data = MapToSecurityPolicyData(policy ?? new DepartmentSecurityPolicy { CreatedOn = DateTime.UtcNow, MinPasswordLength = 8 }); + if (policy == null) + data.UpdatedOn = null; + result.Data = data; return Ok(result); } @@ -366,6 +412,8 @@ public async Task> SaveSecurityPolicy( { if (!ModelState.IsValid) return BadRequest(ModelState); if (!await IsAdminAsync()) return Forbid(); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken); + if (mfaProblem != null) return mfaProblem; // Safety guard: disallow RequireSso=true when no active SSO config exists if (input.RequireSso) @@ -387,6 +435,8 @@ public async Task> SaveSecurityPolicy( CreatedOn = DateTime.UtcNow }; + var storedRules = DepartmentSecurityPolicyDecisions.SnapshotMfaRules(policy); + policy.RequireMfa = input.RequireMfa; policy.RequireSso = input.RequireSso; policy.SessionTimeoutMinutes = input.SessionTimeoutMinutes; @@ -397,7 +447,55 @@ public async Task> SaveSecurityPolicy( policy.RequirePasswordComplexity = input.RequirePasswordComplexity; policy.DataClassificationLevel = input.DataClassificationLevel; - var saved = await _ssoService.SaveSecurityPolicyAsync(policy, cancellationToken); + policy.AllowPasskeysForLoginMfa = input.AllowPasskeysForLoginMfa ?? policy.AllowPasskeysForLoginMfa; + policy.AllowPasskeysForAdp = input.AllowPasskeysForAdp ?? policy.AllowPasskeysForAdp; + policy.AllowFederatedMfaForLoginMfa = input.AllowFederatedMfaForLoginMfa ?? policy.AllowFederatedMfaForLoginMfa; + policy.AllowFederatedMfaForAdp = input.AllowFederatedMfaForAdp ?? policy.AllowFederatedMfaForAdp; + policy.AllowResponderApproval = input.AllowResponderApproval ?? policy.AllowResponderApproval; + policy.AcceptRecentLoginMfaForAdp = input.AcceptRecentLoginMfaForAdp ?? policy.AcceptRecentLoginMfaForAdp; + policy.AcceptRecentUnlockMfaForAdp = input.AcceptRecentUnlockMfaForAdp ?? policy.AcceptRecentUnlockMfaForAdp; + policy.SharedIdleLockMinutes = input.SharedIdleLockMinutes ?? policy.SharedIdleLockMinutes; + policy.SharedShiftHours = input.SharedShiftHours ?? policy.SharedShiftHours; + policy.SharedModeRequiredApps = input.SharedModeRequiredApps ?? policy.SharedModeRequiredApps; + + // Which second factors the department accepts is the managing member's decision, like the other ADP and + // security controls it owns (passkey plan section 10.1). Other administrators can change everything else. + if (DepartmentSecurityPolicyDecisions.MethodSwitchesChanged(storedRules, policy) && !await IsManagingMemberAsync()) + return Problem(type: "managing_member_required", + title: "Only the department's managing member can change which sign-in methods are accepted.", + statusCode: StatusCodes.Status403Forbidden); + + // The shared-device policy is the managing member's too (plan section 10.5). Stricter values reach running shared + // sessions at their next request; requiring shared mode for another app needs the deployment to offer it. + if (DepartmentSecurityPolicyDecisions.SharedPolicyChanged(storedRules, policy)) + { + if (!await IsManagingMemberAsync()) + return Problem(type: "managing_member_required", + title: "Only the department's managing member can change the shared-device policy.", + statusCode: StatusCodes.Status403Forbidden); + if (!DepartmentSecurityPolicyDecisions.SharedPolicyValid(policy)) + return Problem(type: "invalid_request", + title: $"Choose an idle lock of 1-{SharedSessionRules.MaxIdleLockMinutes} minutes, a shift of 1-{SharedSessionRules.MaxShiftHours} hours, and apps from Unit (1), IC (2) and Dispatch (4).", + statusCode: StatusCodes.Status400BadRequest); + if (!_passkeyGates.SharedDeviceModeEnabled && DepartmentSecurityPolicyDecisions.AddsSharedRequirement(storedRules, policy)) + return Problem(type: "shared_mode_unavailable", + title: "Shared-device mode is not available on this deployment yet, so it cannot be required for another app.", + statusCode: StatusCodes.Status409Conflict); + } + + // Turning provider step-up on needs a mapping that passed its test, and cannot be authorized by provider step-up. + if (DepartmentSecurityPolicyDecisions.EnablesFederatedMfa(storedRules, policy)) + { + if (await _ssoService.GetTestedFederatedMfaConfigAsync(DepartmentId, cancellationToken) == null) + return Problem(type: "federated_mapping_untested", + title: "Save and successfully test a provider step-up mapping (SsoAdmin/FederatedMfaTest) before accepting provider MFA.", + statusCode: StatusCodes.Status409Conflict); + + var federatedProblem = await RequireRecentMfaAsync(cancellationToken, excludeFederated: true); + if (federatedProblem != null) return federatedProblem; + } + + var saved = await _ssoService.SaveSecurityPolicyAsync(policy, UserId, cancellationToken); var result = new SaveSecurityPolicyResult(); ResponseHelper.PopulateV4ResponseData(result); @@ -406,6 +504,209 @@ public async Task> SaveSecurityPolicy( return Ok(result); } + // ── Provider step-up mapping (passkey plan section 7.8) ────────────── + + /// The active SSO configuration's provider step-up mapping and whether it has passed its test. + [HttpGet("FederatedMfaMapping")] + [Authorize(Policy = ResgridResources.Sso_View)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetFederatedMfaMapping(CancellationToken cancellationToken) + { + if (!await IsAdminAsync()) return Forbid(); + + var config = await ActiveConfigAsync(cancellationToken); + return config == null ? NotFound() : MappingResult(config); + } + + /// + /// Saves (or removes, with a null mapping) the provider step-up mapping. Managing member only, after an authenticator + /// app or passkey step-up; every change advances the version and needs a new test before it counts. + /// + [HttpPut("FederatedMfaMapping")] + [Authorize(Policy = ResgridResources.Sso_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SaveFederatedMfaMapping([FromBody] SaveFederatedMfaMappingInput input, + CancellationToken cancellationToken) + { + if (!await IsManagingMemberAsync()) + return Problem(type: "managing_member_required", title: "Only the department's managing member can change the provider step-up mapping.", + statusCode: StatusCodes.Status403Forbidden); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken, excludeFederated: true); + if (mfaProblem != null) return mfaProblem; + + var config = await ActiveConfigAsync(cancellationToken); + if (config == null) return NotFound(); + + string mappingJson = null; + if (input?.Mapping != null && input.Mapping.Type != Newtonsoft.Json.Linq.JTokenType.Null) + { + var mapping = Resgrid.Model.Security.FederatedMfaMapping.Parse(input.Mapping.ToString(Newtonsoft.Json.Formatting.None)); + var problem = Resgrid.Model.Security.FederatedMfaMapping.Validate(mapping, (SsoProviderType)config.SsoProviderType); + if (problem != null) + return Problem(type: "invalid_request", title: problem, statusCode: StatusCodes.Status400BadRequest); + mappingJson = mapping.Serialize(); + } + + var changed = !string.Equals(config.FederatedMfaMappingJson ?? string.Empty, mappingJson ?? string.Empty, StringComparison.Ordinal); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + config.FederatedMfaMappingJson = mappingJson; + config.UpdatedByUserId = UserId; + var saved = await _ssoService.SaveSsoConfigAsync(config, department.Code, cancellationToken); + + // Who changed what counts as MFA is audited (plan section 7.8), whichever surface changed it. + if (changed) + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.FederatedMfaMappingChanged, + UserId = UserId, + Username = UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = mappingJson == null + ? $"Provider step-up mapping for SSO configuration {config.DepartmentSsoConfigId} removed (now version {saved?.FederatedMfaMappingVersion})." + : $"Provider step-up mapping for SSO configuration {config.DepartmentSsoConfigId} saved as version {saved?.FederatedMfaMappingVersion}; " + + "it counts once it passes its test." + }, cancellationToken); + + return MappingResult(saved); + } + + /// Starts the managing member's test step-up with the saved mapping; it proves the provider returns a mapped MFA value. + [HttpPost("FederatedMfaTest/Begin")] + [Authorize(Policy = ResgridResources.Sso_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BeginFederatedMfaTest([FromBody] FederatedMfaTestBeginInput input, CancellationToken cancellationToken) + { + if (!await IsManagingMemberAsync()) + return Problem(type: "managing_member_required", title: "Only the department's managing member can test the provider step-up mapping.", + statusCode: StatusCodes.Status403Forbidden); + var mfaProblem = await RequireRecentMfaAsync(cancellationToken, excludeFederated: true); + if (mfaProblem != null) return mfaProblem; + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null || input == null) + return Problem(type: "session_required", title: "Sign in again to test the mapping.", statusCode: StatusCodes.Status409Conflict); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var begun = await _ssoBroker.BeginAsync(new Resgrid.Model.Security.SsoBeginRequest + { + DepartmentId = DepartmentId, + DepartmentCode = department?.Code, + Purpose = Resgrid.Model.Security.SsoTransactionPurpose.MappingTest, + ClientApplication = (UserSessionClientApplication)session.ClientApplication, + Platform = input.Platform, + ReturnTarget = input.ReturnTarget, + ClientState = input.State, + CodeChallenge = input.CodeChallenge, + CodeChallengeMethod = input.CodeChallengeMethod, + SessionId = session.SessionId, + UserId = UserId, + AuthenticationGeneration = session.AuthenticationGeneration + }, cancellationToken); + if (!begun.Succeeded) + return Problem(type: Resgrid.Model.Security.SsoBrokerOutcomes.ErrorCode(begun.Outcome) ?? "sso_failed", + title: "The mapping test could not be started.", statusCode: begun.Outcome == Resgrid.Model.Security.SsoBrokerOutcome.ServiceUnavailable + ? StatusCodes.Status503ServiceUnavailable + : StatusCodes.Status400BadRequest); + + var result = new SsoBeginResult + { + Data = new SsoBeginResultData { AuthorizeUrl = begun.AuthorizeUrl, SsoTransactionId = begun.TransactionId, ExpiresIn = begun.ExpiresInSeconds }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Completes the test: the provider's fresh response carried a value the mapping counts as MFA, so that exact mapping + /// version becomes effective. A mapping changed during the test must be tested again. + /// + [HttpPost("FederatedMfaTest/Complete")] + [Authorize(Policy = ResgridResources.Sso_Update)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CompleteFederatedMfaTest([FromBody] FederatedMfaTestCompleteInput input, + CancellationToken cancellationToken) + { + if (!await IsManagingMemberAsync()) + return Problem(type: "managing_member_required", title: "Only the department's managing member can test the provider step-up mapping.", + statusCode: StatusCodes.Status403Forbidden); + + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null) + return Problem(type: "session_required", title: "Sign in again to test the mapping.", statusCode: StatusCodes.Status409Conflict); + + var redeemed = await _ssoBroker.RedeemAsync(input?.SsoTransactionId, input?.SsoCode, input?.CodeVerifier, + (UserSessionClientApplication)session.ClientApplication, cancellationToken, Resgrid.Model.Security.SsoTransactionPurpose.MappingTest); + var transaction = redeemed.Transaction; + if (!redeemed.Succeeded || !string.Equals(transaction.SessionId, session.SessionId, StringComparison.Ordinal) || + !string.Equals(transaction.ExpectedUserId, UserId, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(transaction.FederatedMfaValue) || transaction.FederatedMappingVersion == null) + return Problem(type: Resgrid.Model.Security.SsoBrokerOutcomes.ErrorCode(redeemed.Succeeded + ? Resgrid.Model.Security.SsoBrokerOutcome.TransactionInvalid + : redeemed.Outcome) ?? "sso_failed", + title: "The mapping test could not be completed. Start it again.", statusCode: StatusCodes.Status400BadRequest); + + if (!await _ssoService.RecordFederatedMfaTestAsync(transaction.DepartmentSsoConfigId, transaction.FederatedMappingVersion.Value, UserId, cancellationToken)) + return Problem(type: "federated_mapping_changed", title: "The mapping changed during the test. Test it again.", + statusCode: StatusCodes.Status409Conflict); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)SystemAuditTypes.FederatedMfaMappingTested, + UserId = UserId, + Username = UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = $"Provider step-up mapping version {transaction.FederatedMappingVersion} for SSO configuration {transaction.DepartmentSsoConfigId} " + + $"passed its test ({transaction.FederatedMfaValue})." + }, cancellationToken); + + var result = new FederatedMfaTestResult + { + Data = new FederatedMfaTestResultData + { + Tested = true, + MatchedValue = transaction.FederatedMfaValue, + MappingVersion = transaction.FederatedMappingVersion.Value + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private async Task ActiveConfigAsync(CancellationToken cancellationToken) => + (await _ssoService.GetSsoConfigsForDepartmentAsync(DepartmentId, cancellationToken))?.FirstOrDefault(c => c.IsEnabled); + + private ActionResult MappingResult(DepartmentSsoConfig config) + { + var result = new FederatedMfaMappingResult + { + Data = new FederatedMfaMappingResultData + { + DepartmentSsoConfigId = config.DepartmentSsoConfigId, + ProviderType = ((SsoProviderType)config.SsoProviderType).ToString().ToLowerInvariant(), + Mapping = string.IsNullOrWhiteSpace(config.FederatedMfaMappingJson) ? null : Newtonsoft.Json.Linq.JToken.Parse(config.FederatedMfaMappingJson), + MappingVersion = config.FederatedMfaMappingVersion, + TestedVersion = config.FederatedMfaTestedVersion, + TestedOn = config.FederatedMfaTestedOnUtc, + TestedByUserId = config.FederatedMfaTestedByUserId, + Effective = Resgrid.Model.Security.FederatedMfaMapping.IsTested(config) + }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + // ── Test / Validation helpers ───────────────────────────────────────── /// @@ -446,6 +747,12 @@ public async Task> TestScimConnection( // ── Private helpers ─────────────────────────────────────────────────── + private async Task IsManagingMemberAsync() + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + return department != null && department.ManagingUserId == UserId; + } + private async Task IsAdminAsync() { var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -477,6 +784,10 @@ private static string ValidateSsoConfiguration(SaveSsoConfigInput input, SsoProv if (string.IsNullOrWhiteSpace(input.IdpCertificate) && string.IsNullOrWhiteSpace(existing?.EncryptedIdpCertificate)) return "An IdP signing certificate is required to validate SAML assertions."; + var idpSsoUrl = input.IdpSsoUrl ?? existing?.IdpSsoUrl; + if (!string.IsNullOrWhiteSpace(idpSsoUrl) && (!Uri.TryCreate(idpSsoUrl, UriKind.Absolute, out var idpSso) || idpSso.Scheme != Uri.UriSchemeHttps)) + return "SAML idpSsoUrl must be a valid HTTPS URL."; + return null; } @@ -499,6 +810,7 @@ private static DepartmentSsoConfig BuildConfigFromInput( MetadataUrl = input.MetadataUrl, EntityId = input.EntityId, AssertionConsumerServiceUrl = input.AssertionConsumerServiceUrl, + IdpSsoUrl = input.IdpSsoUrl, EncryptedIdpCertificate = input.IdpCertificate, EncryptedSigningCertificate = input.SigningCertificate, AttributeMappingJson = input.AttributeMappingJson, @@ -545,6 +857,14 @@ private static SsoConfigDetailData MapToDetail(DepartmentSsoConfig c) => MetadataUrl = c.MetadataUrl, EntityId = c.EntityId, AssertionConsumerServiceUrl = c.AssertionConsumerServiceUrl, + IdpSsoUrl = c.IdpSsoUrl, + OidcBrokerRedirectUri = c.SsoProviderType == (int)SsoProviderType.Oidc + ? $"{Config.SystemBehaviorConfig.ResgridApiBaseUrl?.TrimEnd('/')}{Config.SsoConfig.OidcCallbackPath}" + : null, + OidcAppRedirectUris = c.SsoProviderType == (int)SsoProviderType.Oidc + ? LegacyAppCallbacks.RedirectUris(Config.SsoConfig.AppWebOrigins) + .Select(a => new SsoAppRedirectUriData { Client = a.Name, DisplayName = a.DisplayName, Web = a.Web, RedirectUri = a.Uri }).ToList() + : null, AttributeMappingJson = c.AttributeMappingJson, DefaultRankId = c.DefaultRankId, // Secret presence flags — values never returned @@ -567,6 +887,17 @@ private static SecurityPolicyData MapToSecurityPolicyData(DepartmentSecurityPoli MinPasswordLength = p.MinPasswordLength, RequirePasswordComplexity = p.RequirePasswordComplexity, DataClassificationLevel = p.DataClassificationLevel, + AllowPasskeysForLoginMfa = p.AllowPasskeysForLoginMfa, + AllowPasskeysForAdp = p.AllowPasskeysForAdp, + AllowFederatedMfaForLoginMfa = p.AllowFederatedMfaForLoginMfa, + AllowFederatedMfaForAdp = p.AllowFederatedMfaForAdp, + AllowResponderApproval = p.AllowResponderApproval, + AcceptRecentLoginMfaForAdp = p.AcceptRecentLoginMfaForAdp, + AcceptRecentUnlockMfaForAdp = p.AcceptRecentUnlockMfaForAdp, + SharedIdleLockMinutes = p.SharedIdleLockMinutes, + SharedShiftHours = p.SharedShiftHours, + SharedModeRequiredApps = p.SharedModeRequiredApps, + MfaPolicyVersion = p.MfaPolicyVersion, CreatedOn = p.CreatedOn, UpdatedOn = p.UpdatedOn }; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SsoController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SsoController.cs new file mode 100644 index 000000000..40b6c7923 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/SsoController.cs @@ -0,0 +1,380 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Sso; +using static OpenIddict.Abstractions.OpenIddictConstants; +using SsoBeginResult = Resgrid.Web.Services.Models.v4.Sso.SsoBeginResult; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Server-brokered SSO for every client (passkey plan section 7.7.2; workbook section 7.3). Begin returns the + /// IdP URL; the IdP returns to the server, which sends a one-time code to the client's registered return target; + /// Redeem exchanges that code and the PKCE verifier for the login MFA transaction (or reauthentication). No IdP + /// token or assertion ever reaches the client, and SAML + TOTP works because nothing is resent. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + [AllowAnonymous] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): a locked + // shared session must still unlock or end its shift, and Responder must still approve or deny. + [Resgrid.Web.Services.Filters.AllowDuringDepartmentLock] + public class SsoController : ControllerBase + { + private static readonly string[] LoginScopes = { Scopes.OpenId, Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, Scopes.Roles }; + + private readonly ISsoBrokerService _broker; + private readonly IMfaLoginTransactionService _loginTransactions; + private readonly IMfaPolicyService _mfaPolicy; + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IDepartmentsService _departments; + private readonly UserManager _userManager; + private readonly ISystemAuditsService _systemAudits; + private readonly IDepartmentSsoService _departmentSso; + + public SsoController(ISsoBrokerService broker, IMfaLoginTransactionService loginTransactions, IMfaPolicyService mfaPolicy, + IMfaEvidenceService mfaEvidence, IDepartmentsService departments, UserManager userManager, + ISystemAuditsService systemAudits, IDepartmentSsoService departmentSso) + { + _departmentSso = departmentSso; + _broker = broker; + _loginTransactions = loginTransactions; + _mfaPolicy = mfaPolicy; + _mfaEvidence = mfaEvidence; + _departments = departments; + _userManager = userManager; + _systemAudits = systemAudits; + } + + [HttpPost("Begin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Begin([FromBody] SsoBeginInput input, CancellationToken cancellationToken) + { + if (!_broker.IsEnabled) + return Refuse(SsoBrokerOutcome.Unavailable); + + var purpose = SsoBrokerOutcomes.PurposeFrom(input?.Purpose); + var client = ApiClientApplication.Resolve(string.IsNullOrWhiteSpace(input?.ClientApp) ? Request.Headers[ApiClientApplication.Header] : input.ClientApp); + if (input == null || purpose == null) + return Refuse(SsoBrokerOutcome.InvalidRequest); + + SsoBeginRequest request; + if (purpose == SsoTransactionPurpose.StepUp && !string.IsNullOrWhiteSpace(input.Transaction)) + { + // Provider step-up that completes a password sign-in (plan section 7.6 row 3): bound to that login transaction, + // its account and generation, and redeemed only at Authentication/CompleteFederated. + var opened = await _loginTransactions.OpenAsync(input.Transaction, client, cancellationToken); + if (!opened.IsUsable) + return Problem(type: MfaLoginTransactions.ErrorCode(opened.Outcome) ?? "mfa_transaction_invalid", + title: "This sign-in is no longer valid. Sign in again.", statusCode: StatusCodes.Status400BadRequest); + + var login = opened.Transaction; + if (login.DepartmentId is not int loginDepartment || + !await _loginTransactions.IsMethodAcceptedAsync(login, MfaEvidenceMethod.Federated, cancellationToken) || + !await _departmentSso.IsFederatedMfaAvailableAsync(loginDepartment, login.UserId, cancellationToken)) + return Problem(type: "mfa_method_not_allowed", title: "That verification method is not available for this sign-in.", + statusCode: StatusCodes.Status400BadRequest); + + var department = await _departments.GetDepartmentByIdAsync(loginDepartment); + request = NewRequest(input, department, purpose.Value, client, null, login.UserId, login.AuthenticationGeneration, + SsoLoginTransaction.LoginOperation, login.MfaLoginTransactionId, await SharedInstallationAsync(loginDepartment, client, cancellationToken)); + } + else if (purpose is SsoTransactionPurpose.Reauthentication or SsoTransactionPurpose.StepUp or SsoTransactionPurpose.AdpStepUp) + { + // Reauthentication and step-up are for the signed-in session asking, in its own department; never for another + // account. A step-up names the operation it serves and is redeemed only at Mfa/VerifyStepUp; a Protected Data + // Grant step-up is for the session's department and is redeemed only at DataProtection/CompleteFederated. + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var userId = User.FindFirst(ClaimTypes.PrimarySid)?.Value; + if (session == null || string.IsNullOrWhiteSpace(userId) || !int.TryParse(User.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value, out var sessionDepartment)) + return Problem(type: "session_required", title: "Sign in again to reauthenticate.", statusCode: StatusCodes.Status409Conflict); + + var department = await _departments.GetDepartmentByIdAsync(sessionDepartment); + request = NewRequest(input, department, purpose.Value, (UserSessionClientApplication)session.ClientApplication, + session.SessionId, userId, session.AuthenticationGeneration, purpose == SsoTransactionPurpose.StepUp ? input.Operation : null, null, + session.SharedMode || SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader])); + } + else + { + // An unknown account or department reads the same as one without SSO: nothing about either is revealed. + var department = await _broker.ResolveDepartmentAsync(input.DepartmentToken, input.DepartmentCode, input.Username, cancellationToken); + if (department == null) + return Refuse(SsoBrokerOutcome.Unavailable); + + request = NewRequest(input, department, purpose.Value, client, null, null, null, null, null, + await SharedInstallationAsync(department.DepartmentId, client, cancellationToken)); + } + + var begun = await _broker.BeginAsync(request, cancellationToken); + if (!begun.Succeeded) + return Refuse(begun.Outcome); + + var result = new SsoBeginResult + { + Data = new SsoBeginResultData { AuthorizeUrl = begun.AuthorizeUrl, SsoTransactionId = begun.TransactionId, ExpiresIn = begun.ExpiresInSeconds }, + PageSize = 1, + Status = ResponseHelper.Success + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Redeems the one-time code with the PKCE verifier. A login continues exactly like a password sign-in: MFA through + /// the login transaction when the account has it, a direct completion code when it needs none (plan section 7.6 row + /// 4), and mfa_enrollment_required when the department requires MFA the account lacks. + /// + [HttpPost("Redeem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Redeem([FromBody] SsoRedeemInput input, CancellationToken cancellationToken) + { + var client = ApiClientApplication.Resolve(Request.Headers[ApiClientApplication.Header]); + var redeemed = await _broker.RedeemAsync(input?.SsoTransactionId, input?.SsoCode, input?.CodeVerifier, client, cancellationToken); + if (!redeemed.Succeeded) + return Refuse(redeemed.Outcome); + + var transaction = redeemed.Transaction; + return transaction.TransactionPurpose == SsoTransactionPurpose.Reauthentication + ? await ReauthenticatedAsync(transaction, cancellationToken) + : await LoginAsync(transaction, client, input.ClientId, cancellationToken); + } + + private async Task> LoginAsync(SsoLoginTransaction transaction, UserSessionClientApplication client, string clientId, + CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(transaction.UserId); + if (user == null) + return Refuse(SsoBrokerOutcome.AccessDenied); + if (await _userManager.IsLockedOutAsync(user)) + return Problem(type: "too_many_attempts", title: "Too many failed attempts. Wait a few minutes and sign in again.", + statusCode: StatusCodes.Status429TooManyRequests); + + var membership = await _departments.GetDepartmentMemberAsync(user.Id, transaction.DepartmentId, bypassCache: true); + if (membership == null || membership.IsDeleted || membership.IsDisabled == true) + return Refuse(SsoBrokerOutcome.AccessDenied); + + var totpEnrolled = await _userManager.GetTwoFactorEnabledAsync(user); + var federated = await ProviderMfaSatisfiedAsync(transaction, cancellationToken); + var request = new MfaLoginTransactionRequest + { + UserId = user.Id, + DepartmentId = transaction.DepartmentId, + ClientApplication = client, + ClientId = clientId, + FirstFactorMethod = MfaEvidenceMethod.Sso, + FirstFactorVerifiedOnUtc = transaction.AuthenticatedOnUtc ?? DateTime.UtcNow, + DepartmentSsoConfigId = transaction.DepartmentSsoConfigId, + AuthenticationGeneration = user.AuthenticationGeneration, + Scopes = LoginScopes, + TotpEnrolled = totpEnrolled, + SharedModeRequested = SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]), + InstallationLabel = Request.Headers["X-Resgrid-Device-Name"] + }; + + SsoRedeemResultData data; + if (federated != null) + { + // The sign-in's own round trip carried the department's mapped provider MFA (plan section 7.8 flow 1): that + // satisfies login MFA and RequireMfa with no Resgrid prompt, verified at the provider's authentication time. + var completion = await _loginTransactions.BeginCompletedAsync(request, MfaEvidenceMethod.Federated, + FederatedMfaMapping.FactorReferenceFor(federated.DepartmentSsoConfigId, federated.FederatedMfaMappingVersion), + request.FirstFactorVerifiedOnUtc, cancellationToken); + if (!completion.Succeeded) + return Refuse(SsoBrokerOutcome.ServiceUnavailable); + + data = new SsoRedeemResultData + { + Outcome = "completed", + Transaction = completion.Transaction, + CompletionCode = completion.CompletionCode, + ExpiresIn = completion.ExpiresInSeconds, + MfaSatisfiedBy = MfaMethodNames.Federated + }; + } + else if (totpEnrolled) + { + var start = await _loginTransactions.BeginAsync(request, cancellationToken); + data = new SsoRedeemResultData + { + Outcome = "mfa_required", + Transaction = start.Secret, + ExpiresIn = start.ExpiresInSeconds, + MfaMethods = start.Choice.AllowedMethods.ToList(), + MfaEnrolled = start.Choice.EnrolledMethods.ToList(), + MfaPreferred = start.Choice.Preferred + }; + } + else if (await _mfaPolicy.DepartmentRequiresMfaAsync(transaction.DepartmentId, cancellationToken)) + { + // RequireMfa has always applied to SSO sign-in (plan section 7.6 row 4): no tokens without enrolled MFA. The app + // gets a setup transaction to enroll right here (plan section 6.2); it permits nothing else. + await AuditAsync(user.Id, user.UserName, SystemAuditTypes.SsoLoginFailed, false, "Brokered SSO: mfa_enrollment_required.", cancellationToken); + var enrollment = Problem(type: "mfa_enrollment_required", + title: "Your department requires multi-factor authentication. Set up an authenticator app, then continue.", + statusCode: StatusCodes.Status409Conflict); + if (_loginTransactions.IsEnabled && enrollment.Value is ProblemDetails details) + { + try + { + var setup = await _loginTransactions.BeginAsync(request, cancellationToken); + details.Extensions["mfa_setup_transaction"] = setup.Secret; + details.Extensions["mfa_expires_in"] = setup.ExpiresInSeconds; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "An MFA setup transaction could not be started."); + } + } + + return enrollment; + } + else + { + var completion = await _loginTransactions.BeginCompletedAsync(request, cancellationToken); + if (!completion.Succeeded) + return Refuse(SsoBrokerOutcome.ServiceUnavailable); + + data = new SsoRedeemResultData + { + Outcome = "completed", + Transaction = completion.Transaction, + CompletionCode = completion.CompletionCode, + ExpiresIn = completion.ExpiresInSeconds + }; + } + + await AuditAsync(user.Id, user.UserName, SystemAuditTypes.SsoLogin, true, $"Brokered SSO sign-in: {data.Outcome}.", cancellationToken); + return Result(data); + } + + /// + /// Fresh SSO proof for the session that asked for it (plan section 6.2): the same session, account and generation, + /// recorded as first-factor evidence at the IdP's own authentication time. It signs nobody in. + /// + private async Task> ReauthenticatedAsync(SsoLoginTransaction transaction, CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + var userId = User.FindFirst(ClaimTypes.PrimarySid)?.Value; + if (session == null || !string.Equals(session.SessionId, transaction.SessionId, StringComparison.Ordinal) || + !string.Equals(userId, transaction.ExpectedUserId, StringComparison.OrdinalIgnoreCase) || + session.AuthenticationGeneration != transaction.AuthenticationGeneration) + return Refuse(SsoBrokerOutcome.IdentityMismatch); + + var verifiedAt = transaction.AuthenticatedOnUtc ?? DateTime.UtcNow; + try + { + await _mfaEvidence.RecordAsync(userId, MfaEvidence.TrackedSessionKey(session.SessionId), (UserSessionClientApplication)session.ClientApplication, + MfaEvidenceKind.FirstFactor, MfaEvidenceMethod.Sso, MfaEvidencePurpose.Reauthentication, verifiedAt, session.AuthenticationGeneration, + transaction.DepartmentId, cancellationToken: cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Framework.Logging.LogException(ex, "SSO reauthentication evidence could not be recorded."); + return Refuse(SsoBrokerOutcome.ServiceUnavailable); + } + + await AuditAsync(userId, User.FindFirst(ClaimTypes.Name)?.Value, SystemAuditTypes.AccountReauthenticated, true, + "Reauthenticated through the department's SSO provider.", cancellationToken); + return Result(new SsoRedeemResultData { Outcome = "reauthenticated", VerifiedAt = verifiedAt.ToString("O") }); + } + + /// + /// The tested SSO configuration when the sign-in's round trip carried provider MFA the department accepts for login + /// under its current mapping; null otherwise, and the sign-in continues as any SSO first factor. + /// + private async Task ProviderMfaSatisfiedAsync(SsoLoginTransaction transaction, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(transaction.FederatedMfaValue)) + return null; + + var config = await _departmentSso.GetTestedFederatedMfaConfigAsync(transaction.DepartmentId, cancellationToken); + return FederatedMfaMapping.Satisfies(transaction, config) && + await _mfaPolicy.IsMethodAcceptedAsync(transaction.DepartmentId, MfaMethodScope.Login, MfaEvidenceMethod.Federated, cancellationToken) + ? config + : null; + } + + /// + /// Whether a sign-in is from a shared installation: it says so, or the department makes this app's sessions shared. + /// Either way the provider is asked to let the operator choose the account (plan section 12.5.2). + /// + private async Task SharedInstallationAsync(int departmentId, UserSessionClientApplication client, CancellationToken cancellationToken) => + SharedSessionRules.IsRequested(Request.Headers[SharedSessionRules.InstallationHeader]) || + SharedSessionRules.IsRequiredFor(await _departmentSso.GetSecurityPolicyForDepartmentAsync(departmentId, cancellationToken), client); + + private static SsoBeginRequest NewRequest(SsoBeginInput input, Model.Department department, SsoTransactionPurpose purpose, + UserSessionClientApplication client, string sessionId, string userId, long? generation, string operation, string loginTransactionId, + bool sharedInstallation) => new() + { + DepartmentId = department?.DepartmentId ?? 0, + DepartmentCode = department?.Code, + Purpose = purpose, + ClientApplication = client, + Platform = input.Platform, + ReturnTarget = input.ReturnTarget, + ClientState = input.State, + CodeChallenge = input.CodeChallenge, + CodeChallengeMethod = input.CodeChallengeMethod, + SessionId = sessionId, + UserId = userId, + AuthenticationGeneration = generation, + Operation = operation, + LoginTransactionId = loginTransactionId, + SharedInstallation = sharedInstallation + }; + + private ActionResult Result(SsoRedeemResultData data) + { + var result = new SsoRedeemResult { Data = data, PageSize = 1, Status = ResponseHelper.Success }; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + private ObjectResult Refuse(SsoBrokerOutcome outcome) => Problem( + type: SsoBrokerOutcomes.ErrorCode(outcome) ?? "sso_failed", + title: outcome switch + { + SsoBrokerOutcome.Unavailable => "Single sign-on is not available for this sign-in.", + SsoBrokerOutcome.ReturnTargetNotAllowed => "That return address is not registered for this app.", + SsoBrokerOutcome.Expired => "The sign-in took too long. Start again.", + SsoBrokerOutcome.IdentityMismatch => "The identity provider signed in a different account. Start again.", + SsoBrokerOutcome.AccessDenied => "This account cannot sign in here.", + SsoBrokerOutcome.ServiceUnavailable => "Sign-in is temporarily unavailable. Try again.", + SsoBrokerOutcome.FederatedNotSatisfied => "Your identity provider did not confirm multi-factor authentication. Start again.", + _ => "The sign-in could not be completed. Start again." + }, + statusCode: outcome switch + { + SsoBrokerOutcome.IdentityMismatch or SsoBrokerOutcome.AccessDenied => StatusCodes.Status403Forbidden, + SsoBrokerOutcome.VerificationFailed or SsoBrokerOutcome.ReauthenticationNotFresh or SsoBrokerOutcome.FederatedNotSatisfied => + StatusCodes.Status401Unauthorized, + SsoBrokerOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status400BadRequest + }); + + private Task AuditAsync(string userId, string userName, SystemAuditTypes type, bool successful, string data, CancellationToken cancellationToken) => + _systemAudits.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Api, + Type = (int)type, + UserId = userId, + Username = userName, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = data + }, cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Services/Dockerfile b/Web/Resgrid.Web.Services/Dockerfile index 930e71b77..3297151f2 100644 --- a/Web/Resgrid.Web.Services/Dockerfile +++ b/Web/Resgrid.Web.Services/Dockerfile @@ -35,6 +35,7 @@ COPY ["Providers/Resgrid.Providers.Pdf/Resgrid.Providers.Pdf.csproj", "Providers COPY ["Providers/Resgrid.Providers.Claims/Resgrid.Providers.Claims.csproj", "Providers/Resgrid.Providers.Claims/"] COPY ["Providers/Resgrid.Providers.Migrations/Resgrid.Providers.Migrations.csproj", "Providers/Resgrid.Providers.Migrations/"] COPY ["Providers/Resgrid.Providers.Voip/Resgrid.Providers.Voip.csproj", "Providers/Resgrid.Providers.Voip/"] +COPY ["Providers/Resgrid.Providers.Authentication/Resgrid.Providers.Authentication.csproj", "Providers/Resgrid.Providers.Authentication/"] RUN dotnet restore "Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj" COPY . . WORKDIR "/src/Web/Resgrid.Web.Services" diff --git a/Web/Resgrid.Web.Services/Helpers/ApiClientApplication.cs b/Web/Resgrid.Web.Services/Helpers/ApiClientApplication.cs new file mode 100644 index 000000000..315d305f9 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/ApiClientApplication.cs @@ -0,0 +1,32 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// The client application a sign-in request says it is (X-Resgrid-Client). A label for session metadata and for + /// binding a login transaction to the app that started it; never proof of the app's identity. + /// + public static class ApiClientApplication + { + public const string Header = "X-Resgrid-Client"; + + public static UserSessionClientApplication Resolve(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return UserSessionClientApplication.Api; + + return value.Trim().ToLowerInvariant() switch + { + "web" => UserSessionClientApplication.Web, + "responder" => UserSessionClientApplication.Responder, + "unit" => UserSessionClientApplication.Unit, + "dispatch" => UserSessionClientApplication.Dispatch, + "bigboard" => UserSessionClientApplication.BigBoard, + "command" => UserSessionClientApplication.Command, + "ic" => UserSessionClientApplication.Command, + "mcp" => UserSessionClientApplication.Mcp, + _ => UserSessionClientApplication.Api + }; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ApiPasskeys.cs b/Web/Resgrid.Web.Services/Helpers/ApiPasskeys.cs new file mode 100644 index 000000000..e58f18744 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/ApiPasskeys.cs @@ -0,0 +1,98 @@ +using System; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Model; +using Resgrid.Model.Security; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// Shared v4 plumbing for passkey ceremonies: the caller comes from the session that request validation accepted, and + /// each service outcome maps to one status and one code from the shared vocabulary (workbook section 7.6). + /// + public static class ApiPasskeys + { + public static PasskeyCaller Caller(HttpContext httpContext, string userId, string userName, int? departmentId) => + PasskeyCaller.From(HttpProtectedGrantContext.SessionOf(httpContext), userId, userName, departmentId, SystemAuditSystems.Api, + IpAddressHelper.GetRequestIP(httpContext.Request, true)); + + /// + /// The WebAuthn credential JSON as the client sent it: a JSON object (PublicKeyCredential.toJSON()) or a string + /// holding one. Null when absent. + /// + public static string CredentialJson(JToken credential) => credential switch + { + null => null, + { Type: JTokenType.Null } => null, + { Type: JTokenType.String } => credential.Value(), + _ => credential.ToString(Formatting.None) + }; + + /// Embeds the server's options JSON unchanged, as an object rather than an escaped string. + public static JRaw Options(string optionsJson) => string.IsNullOrWhiteSpace(optionsJson) ? null : new JRaw(optionsJson); + + public static int StatusFor(PasskeyOutcome outcome) => outcome switch + { + PasskeyOutcome.InvalidRequest or PasskeyOutcome.Unavailable or PasskeyOutcome.NotRegisteredForClient => StatusCodes.Status400BadRequest, + PasskeyOutcome.StepUpRequired or PasskeyOutcome.ReauthenticationRequired or PasskeyOutcome.VerificationFailed => StatusCodes.Status401Unauthorized, + PasskeyOutcome.NotFound => StatusCodes.Status404NotFound, + PasskeyOutcome.TooManyRequests or PasskeyOutcome.TooManyAttempts => StatusCodes.Status429TooManyRequests, + PasskeyOutcome.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status409Conflict + }; + + public static string TitleFor(PasskeyOutcome outcome) => outcome switch + { + PasskeyOutcome.Unavailable => "Passkeys are not available here.", + PasskeyOutcome.SessionRequired => "Sign in again to manage passkeys.", + PasskeyOutcome.ReauthenticationRequired => "Confirm your password (or sign in with SSO) again first.", + PasskeyOutcome.StepUpRequired => "Verify with your authenticator app or a passkey for this app first.", + PasskeyOutcome.EnrollmentRequired => "Set up an authenticator app and recovery codes before adding a passkey.", + PasskeyOutcome.LimitReached => "This app already has the maximum number of passkeys. Remove one first.", + PasskeyOutcome.TooManyRequests or PasskeyOutcome.TooManyAttempts => "Too many attempts. Wait a few minutes and try again.", + PasskeyOutcome.ChallengeExpired => "The passkey request expired. Start again.", + PasskeyOutcome.ChallengeConsumed => "The passkey request was already used. Start again.", + PasskeyOutcome.VerificationFailed => "The passkey could not be verified.", + PasskeyOutcome.NotRegisteredForClient => "You have no passkey for this app.", + PasskeyOutcome.NotFound => "Passkey not found.", + PasskeyOutcome.InvalidRequest => "The request is not valid.", + PasskeyOutcome.ServiceUnavailable => "The passkey service is unavailable. Try again.", + _ => "The passkey request failed." + }; + + /// The API name of a client, as the relying-party configuration uses it. + public static string ClientName(UserSessionClientApplication client) => client switch + { + UserSessionClientApplication.Web => "web", + UserSessionClientApplication.Responder => "responder", + UserSessionClientApplication.Unit => "unit", + UserSessionClientApplication.Dispatch => "dispatch", + UserSessionClientApplication.Command => "ic", + _ => null + }; + + public static UserSessionClientApplication? ClientFromName(string name) => name?.Trim().ToLowerInvariant() switch + { + "web" => UserSessionClientApplication.Web, + "responder" => UserSessionClientApplication.Responder, + "unit" => UserSessionClientApplication.Unit, + "dispatch" => UserSessionClientApplication.Dispatch, + "ic" or "command" => UserSessionClientApplication.Command, + _ => null + }; + + /// The clients a passkey can be bound to, in display order (plan section 6.5). + public static readonly UserSessionClientApplication[] PasskeyClients = + { + UserSessionClientApplication.Web, + UserSessionClientApplication.Responder, + UserSessionClientApplication.Unit, + UserSessionClientApplication.Dispatch, + UserSessionClientApplication.Command + }; + + public static string Iso(DateTime? utc) => + utc == null ? null : DateTime.SpecifyKind(utc.Value, DateTimeKind.Utc).ToString("O"); + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ApiStepUpEvidence.cs b/Web/Resgrid.Web.Services/Helpers/ApiStepUpEvidence.cs new file mode 100644 index 000000000..4012dcd45 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/ApiStepUpEvidence.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// Server-side MFA evidence for the API caller's validated session (passkey plan section 7.6). The session and the + /// generation come from session validation, never from token claims; without a tracked session there is no evidence. + /// A Web session's API bridge carries the Web session's id, so evidence recorded on Web counts here too. + /// + public static class ApiStepUpEvidence + { + public static string SessionKey(HttpContext httpContext) => + MfaEvidence.TrackedSessionKey(HttpProtectedGrantContext.SessionOf(httpContext)?.SessionId); + + /// + /// True when this session completed an actual second factor within , with a method the + /// department accepts for now (passkey plan section 10.1). With + /// , provider step-up does not count: it cannot authorize turning itself on or + /// changing its own mapping (plan section 7.8). + /// + public static async Task HasRecentSecondFactorAsync(IMfaEvidenceService evidence, IMfaPolicyService policy, string userId, + HttpContext httpContext, int? departmentId, MfaMethodScope scope, TimeSpan window, CancellationToken cancellationToken = default, + bool excludeFederated = false) + { + var session = HttpProtectedGrantContext.SessionOf(httpContext); + var sessionKey = MfaEvidence.TrackedSessionKey(session?.SessionId); + if (evidence == null || policy == null || sessionKey == null || string.IsNullOrWhiteSpace(userId)) + return false; + + var latest = await evidence.GetLatestSecondFactorAsync(userId, sessionKey, session.AuthenticationGeneration, cancellationToken); + return MfaEvidenceService.IsFresh(latest, window, DateTime.UtcNow) && + !(excludeFederated && latest.Method == (int)MfaEvidenceMethod.Federated) && + await policy.IsEvidenceAcceptedAsync(departmentId, scope, latest, cancellationToken); + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs b/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs index 807d7c25a..939268779 100644 --- a/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs +++ b/Web/Resgrid.Web.Services/Helpers/HttpProtectedGrantContext.cs @@ -60,5 +60,15 @@ public bool IsWorkloadCaller return context?.User?.Identity == null || !context.User.Identity.IsAuthenticated; } } + + /// + /// The session the request's session validation accepted (passkey plan section 8.3); null for a workload, an + /// untracked session, or a request that did not pass session validation. + /// + public Resgrid.Model.Security.ProtectedGrantSessionContext Session => IsWorkloadCaller ? null : SessionOf(_accessor?.HttpContext); + + /// The validated session of an HTTP request, for controllers that check a grant themselves. + public static Resgrid.Model.Security.ProtectedGrantSessionContext SessionOf(HttpContext context) => + context?.Items[Resgrid.Model.Security.ProtectedGrantSessionContext.HttpItemKey] as Resgrid.Model.Security.ProtectedGrantSessionContext; } } diff --git a/Web/Resgrid.Web.Services/Helpers/LegacySamlRelay.cs b/Web/Resgrid.Web.Services/Helpers/LegacySamlRelay.cs new file mode 100644 index 000000000..6b9efd00b --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/LegacySamlRelay.cs @@ -0,0 +1,62 @@ +using System; +using System.Text.RegularExpressions; +using Resgrid.Model.Security; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// Where the legacy SAML relay (connect/saml-mobile-callback) sends the app back. Every department registers the + /// same ACS URL with its IdP, so the only per-sign-in value that comes back is the RelayState the app sent. An app tags + /// it with its own name (unit.<nonce>), and the relay returns to that app's own auth/callback, + /// echoing the RelayState as relay_state so the app can check that the sign-in is the one it started (login CSRF). + /// The return targets are the fixed ones in , one custom scheme per app; a RelayState + /// naming a target is never trusted. An untagged or unrecognized RelayState keeps the original behavior: Responder's + /// scheme, with nothing echoed. + /// + public static class LegacySamlRelay + { + /// The app an untagged RelayState returns to, as the relay always did before apps tagged it. + public static string DefaultClient => LegacyAppCallbacks.Default.Name; + + // The app's name, a dot, and its one-time nonce (a UUID in today's apps). Anything else is not an app's RelayState, + // and it is never echoed: SAML bindings cap RelayState at 80 bytes, and the charset keeps it inert in a URL. The end + // anchor is \z, not $: in .NET, $ also matches before a trailing newline. + private static readonly Regex AppRelayState = new(@"^(responder|unit|dispatch|ic)\.([A-Za-z0-9_-]{16,64})\z", + RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100)); + + /// The app to return to, its callback, and the RelayState to echo (null when there is nothing to echo). + public sealed record AppReturn(string Client, string CallbackTarget, string EchoedRelayState); + + public static AppReturn For(string relayState) + { + if (!string.IsNullOrEmpty(relayState) && relayState.Length <= 80) + { + Match match; + try + { + match = AppRelayState.Match(relayState); + } + catch (RegexMatchTimeoutException) + { + match = Match.Empty; + } + + var app = match.Success ? LegacyAppCallbacks.ForName(match.Groups[1].Value) : null; + if (app != null) + return new AppReturn(app.Name, app.Callback, relayState); + } + + return new AppReturn(LegacyAppCallbacks.Default.Name, LegacyAppCallbacks.Default.Callback, null); + } + + /// + /// The deep link for the app: the single-use relay token (never the assertion), the encrypted department token the app + /// sends back to external-token, and the echoed RelayState when the app sent one. + /// + public static string DeepLink(AppReturn appReturn, string relayToken, string departmentToken) + { + var link = $"{appReturn.CallbackTarget}?saml_response={Uri.EscapeDataString(relayToken)}&department_token={Uri.EscapeDataString(departmentToken)}"; + return appReturn.EchoedRelayState == null ? link : $"{link}&relay_state={Uri.EscapeDataString(appReturn.EchoedRelayState)}"; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/ResgridTokenEndpoints.cs b/Web/Resgrid.Web.Services/Helpers/ResgridTokenEndpoints.cs new file mode 100644 index 000000000..752901f1c --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/ResgridTokenEndpoints.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.AspNetCore; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Server; +using OpenIddict.Server.AspNetCore; +using Resgrid.Model.Security; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// The OpenIddict token endpoints and grants the API serves, shared by Startup and the HTTP tests so both run the same + /// configuration. + /// + public static class ResgridTokenEndpoints + { + public const string TokenPath = "/api/v4/connect/token"; + + /// The legacy SSO exchange (plan section 7.7.4), which answers with a token response like the token endpoint. + public const string ExternalTokenPath = "/api/v4/connect/external-token"; + + /// + /// The grant every request to carries. App builds post there without a + /// grant_type, so the server supplies it; the route only ever performs the SSO exchange. + /// + public const string ExternalTokenGrantType = "urn:resgrid:params:oauth:grant-type:external_token"; + + public static OpenIddictServerBuilder UseResgridTokenEndpoints(this OpenIddictServerBuilder options) + { + options.RegisterScopes(OpenIddict.Abstractions.OpenIddictConstants.Scopes.Profile, OpenIddict.Abstractions.OpenIddictConstants.Scopes.Email, + OpenIddict.Abstractions.OpenIddictConstants.Scopes.OfflineAccess, "mobile", "web"); + + // The legacy exchange signs in through OpenIddict, which only issues tokens from a registered token endpoint. + // Without it every exchange that passed its checks ended in a 500 (workbook section 12, slice 9). + options.SetTokenEndpointUris(TokenPath, ExternalTokenPath); + + options.AllowClientCredentialsFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow() + .AllowCustomFlow("web_session") + // One-use completion of a login MFA transaction (passkey workbook section 7.1). + .AllowCustomFlow(MfaLoginTransactions.CompletionGrantType) + .AllowCustomFlow(ExternalTokenGrantType); + + options.AddEventHandler(handler => handler + .UseInlineHandler(context => + { + // Whatever grant_type a caller sent here is replaced: this path never refreshes, never takes a password, + // and never completes an MFA transaction, so nothing else is validated or honored on it. + var path = context.Transaction.GetHttpRequest()?.Path; + if (context.Request != null && path.HasValue && + string.Equals(path.Value.Value?.TrimEnd('/'), ExternalTokenPath, StringComparison.OrdinalIgnoreCase)) + context.Request.GrantType = ExternalTokenGrantType; + return default; + }) + .SetOrder(OpenIddictServerAspNetCoreHandlers.ExtractPostRequest.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.Custom)); + + return options; + } + } +} diff --git a/Web/Resgrid.Web.Services/Helpers/SharedSessionEndpoints.cs b/Web/Resgrid.Web.Services/Helpers/SharedSessionEndpoints.cs new file mode 100644 index 000000000..e3012d1b2 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/SharedSessionEndpoints.cs @@ -0,0 +1,34 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Resgrid.Model; + +namespace Resgrid.Web.Services.Helpers +{ + /// + /// The narrow set of API endpoints a locked shared session may still reach (passkey plan section 12.5.3): its own status, + /// lock, unlock and end shift. Everything else refuses it, including refresh, grants and SignalR. + /// + public static class SharedSessionEndpoints + { + /// HttpContext.Items key under which session validation leaves the validated (or locked) session row. + public const string SessionItemKey = "Resgrid.ValidatedUserSession"; + + private static readonly Regex LockedPaths = new( + @"^/api/v[0-9.]+/sessions/(current|lock|end-shift|unlock-options|unlock-sso|complete-unlock|unlock-approval(/[^/]+)?)/?$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static bool AcceptsLockedSession(HttpRequest request) => + request?.Path.HasValue == true && LockedPaths.IsMatch(request.Path.Value); + + /// The session row this request was validated against (locked only on the endpoints above); null otherwise. + public static UserSession SessionOf(HttpContext httpContext) => + httpContext?.Items.TryGetValue(SessionItemKey, out var value) == true ? value as UserSession : null; + + /// True when the client marked this request as caused by the operator (X-Resgrid-Operator-Activity: 1). + public static bool IsOperatorActivity(HttpRequest request) + { + var value = request?.Headers[SharedSessionRules.ActivityHeader].ToString().Trim(); + return value == "1" || string.Equals(value, "true", System.StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/SessionConnectionSweepService.cs b/Web/Resgrid.Web.Services/Middleware/SessionConnectionSweepService.cs new file mode 100644 index 000000000..2357109ed --- /dev/null +++ b/Web/Resgrid.Web.Services/Middleware/SessionConnectionSweepService.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Resgrid.Config; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Web.Services.Middleware +{ + /// + /// Closes this host's SignalR connections whose session ended, locked or passed its idle deadline, every + /// SessionSecurityConfig.ConnectionSweepIntervalSeconds (passkey workbook section 12, slice 16). Invocations are + /// validated as they arrive; this stops a connection that only listens from receiving broadcasts after its session ends. + /// + public sealed class SessionConnectionSweepService : BackgroundService + { + private readonly SessionConnectionRegistry _connections; + private readonly IServiceScopeFactory _scopes; + + public SessionConnectionSweepService(SessionConnectionRegistry connections, IServiceScopeFactory scopes) + { + _connections = connections; + _scopes = scopes; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (SessionSecurityConfig.ConnectionSweepIntervalSeconds <= 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(5, SessionSecurityConfig.ConnectionSweepIntervalSeconds))); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + using var scope = _scopes.CreateScope(); + await _connections.SweepAsync(scope.ServiceProvider.GetRequiredService(), stoppingToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // The next tick tries again; invocations are still validated one by one meanwhile. + Resgrid.Framework.Logging.LogException(ex, "The SignalR session sweep failed."); + } + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs b/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs index 21a337535..67af7b5a9 100644 --- a/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs +++ b/Web/Resgrid.Web.Services/Middleware/SessionValidationHubFilter.cs @@ -13,10 +13,12 @@ namespace Resgrid.Web.Services.Middleware public class SessionValidationHubFilter : IHubFilter { private readonly IUserSessionService _userSessionService; + private readonly Resgrid.Services.SessionConnectionRegistry _connections; - public SessionValidationHubFilter(IUserSessionService userSessionService) + public SessionValidationHubFilter(IUserSessionService userSessionService, Resgrid.Services.SessionConnectionRegistry connections) { _userSessionService = userSessionService; + _connections = connections; } public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, @@ -31,11 +33,54 @@ public async ValueTask InvokeMethodAsync(HubInvocationContext invocation return await next(invocationContext); } - public Task OnConnectedAsync(HubLifetimeContext context, Func next) => - next(context); + /// + /// A connection with a user session joins that session's group, for events meant for it alone, and is tracked so the + /// sweep can close it once the session ends or locks (slice 16). + /// + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + var sessionId = SessionIdOf(context.Context.User); + if (sessionId == null) + { + await next(context); + return; + } + + _connections.Register(context.Context.ConnectionId, sessionId, context.Context.Abort); + try + { + await context.Hub.Groups.AddToGroupAsync(context.Context.ConnectionId, SessionEvents.GroupFor(sessionId)); + await next(context); + } + catch + { + // SignalR never calls OnDisconnectedAsync for a connection whose OnConnectedAsync failed. + _connections.Unregister(context.Context.ConnectionId); + throw; + } + } public Task OnDisconnectedAsync(HubLifetimeContext context, Exception exception, - Func next) => next(context, exception); + Func next) + { + _connections.Unregister(context.Context.ConnectionId); + return next(context, exception); + } + + /// The tracked user session a connection belongs to; null for workloads and pre-session tokens. + private static string SessionIdOf(ClaimsPrincipal principal) + { + if (principal?.Identity?.IsAuthenticated != true) + return null; + + var userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? principal.FindFirstValue(ClaimTypes.PrimarySid) ?? + principal.FindFirstValue(OpenIddictConstants.Claims.Subject); + if (string.IsNullOrWhiteSpace(userId) || userId.StartsWith("dept_", StringComparison.Ordinal) || userId.StartsWith("system_", StringComparison.Ordinal)) + return null; + + var sessionId = principal.FindFirstValue(SessionClaimTypes.SessionId); + return string.IsNullOrWhiteSpace(sessionId) ? null : sessionId; + } private async Task IsValidAsync(HubCallerContext context) { diff --git a/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs b/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs index 9fa3c1e0e..d832d0ad7 100644 --- a/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs +++ b/Web/Resgrid.Web.Services/Middleware/SessionValidationMiddleware.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using OpenIddict.Abstractions; +using Resgrid.Model; using Resgrid.Model.Security; using Resgrid.Model.Services; using Resgrid.Web.Services.Helpers; @@ -82,11 +83,56 @@ public async Task InvokeAsync(HttpContext context, IUserSessionService userSessi if (!validation.IsValid) { + // A locked shared session reaches only its own status, lock, unlock and end-shift endpoints (plan section + // 12.5.3); they read the locked row from here. It gets no grant context and records no activity. + if (validation.IsLocked && validation.Session != null && SharedSessionEndpoints.AcceptsLockedSession(context.Request)) + { + context.Items[SharedSessionEndpoints.SessionItemKey] = validation.Session; + await _next(context); + return; + } + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + if (validation.IsLocked || validation.FailureCode == SharedSessionRules.ExpiredFailureCode) + { + // The token is still what unlocks the session, so a shared-mode client is told why rather than only + // that the token is refused. A client that does not know shared mode signs in again, which is also safe. + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"invalid_token\", error_description=\"{validation.FailureCode}\""; + await context.Response.WriteAsJsonAsync(new SharedSessionRefusal + { + Error = validation.FailureCode, + LockVersion = validation.IsLocked ? validation.Session?.LockVersion : null + }, context.RequestAborted); + return; + } + context.Response.Headers.WWWAuthenticate = "Bearer error=\"invalid_token\""; return; } + if (validation.Session != null) + context.Items[SharedSessionEndpoints.SessionItemKey] = validation.Session; + + // The session this request was just validated against is what a version 2 Protected Data Grant + // must match (passkey plan section 8.3); nothing downstream re-derives it from token claims. + var grantSession = ProtectedGrantSessionContext.From(validation.Session, GetIssuedOn(principal)); + if (grantSession != null) + context.Items[ProtectedGrantSessionContext.HttpItemKey] = grantSession; + + // Operator activity moves a shared session's idle deadline only when the client marks the request as the + // operator's (plan section 10.5); background polling and sockets never do. + if (validation.Session?.SharedMode == true && SharedSessionEndpoints.IsOperatorActivity(context.Request)) + { + try + { + await userSessionService.RecordOperatorActivityAsync(validation.Session, context.RequestAborted); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Resgrid.Framework.Logging.LogException(ex, "Shared session operator activity update failed."); + } + } + // Skip the write when the recorded activity is still inside the write interval: without this // every authenticated request pays a location lookup and a database round trip to update no // rows. It also collapses the duplicate touch a SignalR connection would otherwise make @@ -112,6 +158,16 @@ public async Task InvokeAsync(HttpContext context, IUserSessionService userSessi await _next(context); } + private sealed class SharedSessionRefusal + { + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string Error { get; init; } + + [System.Text.Json.Serialization.JsonPropertyName("lock_version")] + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public long? LockVersion { get; init; } + } + private static DateTime? GetIssuedOn(ClaimsPrincipal principal) { var value = principal.FindFirstValue(OpenIddictConstants.Claims.IssuedAt); diff --git a/Web/Resgrid.Web.Services/Models/v4/AccountSecurity/AccountSecurityModels.cs b/Web/Resgrid.Web.Services/Models/v4/AccountSecurity/AccountSecurityModels.cs new file mode 100644 index 000000000..551357bd1 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/AccountSecurity/AccountSecurityModels.cs @@ -0,0 +1,248 @@ +using System.Collections.Generic; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Models.v4.AccountSecurity +{ + /// + /// The signed-in user's own sign-in methods (plan section 6.5): the same view in every app. + /// + public class AccountMethodsResult : StandardApiResponseV4Base + { + public AccountMethodsResultData Data { get; set; } + } + + public class AccountMethodsResultData + { + /// The app this request came from: web, responder, unit, dispatch or ic. + public string CurrentClient { get; set; } + + public TotpMethodData Totp { get; set; } + + /// One group per app a passkey can be bound to, in display order. + public List PasskeyGroups { get; set; } + + /// Responder installations that can receive approval requests; stop them through MfaApproval/Installations/Disable. + public List ApprovalInstallations { get; set; } + + /// Department identity-provider links. Informational: the department and SCIM manage them. + public List LinkedIdentities { get; set; } + + /// Verifications in the last TwoFactorConfig.MfaActivityRetentionDays days, newest first (at most 100). + public List RecentActivity { get; set; } + } + + public class ApprovalInstallationData + { + /// Pass to MfaApproval/Installations/Disable. + public string InstallationId { get; set; } + + public string Label { get; set; } + public string Platform { get; set; } + + /// This request came from this installation. + public bool IsCurrent { get; set; } + + /// Requests reach it now: approvals are not stopped here and a Responder passkey can approve. + public bool ApprovalsOn { get; set; } + + public string StoppedOn { get; set; } + public string LastDecisionOn { get; set; } + + /// approved or denied; null when it has decided nothing recently. + public string LastDecision { get; set; } + } + + public class LinkedIdentityData + { + public int DepartmentId { get; set; } + public string DepartmentName { get; set; } + + /// saml2 or oidc. + public string ProviderType { get; set; } + + public string LinkedOn { get; set; } + + /// The department accepts provider step-up and its mapping is tested, so this identity can verify there. + public bool AcceptsProviderStepUp { get; set; } + + public string LastProviderStepUpOn { get; set; } + } + + public class MfaActivityData + { + /// Pass to AccountSecurity/ReportActivity. + public string ActivityId { get; set; } + + public string OccurredOn { get; set; } + + /// totp, passkey, passkey_approval, federated or recovery_code. + public string Method { get; set; } + + /// login, reauthentication, step_up, adp_step_up or shared_unlock. + public string Purpose { get; set; } + + /// False for a denied verification: a wrong code, a failed passkey or a denied approval. + public bool Successful { get; set; } + + public string Client { get; set; } + public string Installation { get; set; } + public bool SharedInstallation { get; set; } + + /// It was for the session making this request; reporting it does not end this session. + public bool IsCurrentSession { get; set; } + + public string ReportedOn { get; set; } + } + + public class ReportActivityInput + { + public string ActivityId { get; set; } + } + + public class ReportActivityResult : StandardApiResponseV4Base + { + public ReportActivityResultData Data { get; set; } + } + + public class ReportActivityResultData + { + /// The session the verification opened or served was ended. + public bool SessionEnded { get; set; } + + /// What the app should offer next: change_password, review_methods. + public List NextSteps { get; set; } + } + + /// Resgrid cannot see where copies of an authenticator's setup key exist; replacing it is the remedy. + public class TotpMethodData + { + public bool Enrolled { get; set; } + public string EnrolledOn { get; set; } + public string LastUsedOn { get; set; } + public int RecoveryCodesRemaining { get; set; } + public bool RecoveryCodeWarning { get; set; } + + /// The current authenticator was set up on a shared installation, so its setup key may have been seen there. + public bool SetUpOnSharedInstallation { get; set; } + + /// The app and installation the current authenticator was set up or last replaced from, when recorded. + public string EnrolledClient { get; set; } + + public string EnrolledInstallation { get; set; } + + /// Where its last successful use in the recent-activity window came from. + public string LastUsedClient { get; set; } + + public string LastUsedInstallation { get; set; } + + /// False while any passkey exists: turning TOTP off is blocked in this release (plan section 7.5 rule 7). + public bool CanTurnOff { get; set; } + } + + public class PasskeyClientGroupData + { + public string Client { get; set; } + + /// Whether a passkey for this app can be registered on this deployment now (from that app). + public bool RegistrationAvailable { get; set; } + + public List Passkeys { get; set; } + } + + public class ReauthenticateInput + { + public string Password { get; set; } + } + + public class ReauthenticateResult : StandardApiResponseV4Base + { + public ReauthenticateResultData Data { get; set; } + } + + public class ReauthenticateResultData + { + public string VerifiedAt { get; set; } + } + + /// A new authenticator key, staged; add it to an authenticator app, then send a code from it to ReplaceTotp. + public class ReplaceTotpOptionsResult : StandardApiResponseV4Base + { + public ReplaceTotpOptionsResultData Data { get; set; } + } + + public class ReplaceTotpOptionsResultData + { + /// The key to type into an authenticator app, in groups of four. + public string SharedKey { get; set; } + + /// The otpauth:// URI to show as a QR code. + public string AuthenticatorUri { get; set; } + + public int ExpiresIn { get; set; } + } + + public class ReplaceTotpInput + { + /// A code from the new authenticator. + public string Code { get; set; } + } + + public class ReplaceTotpResult : StandardApiResponseV4Base + { + public ReplaceTotpResultData Data { get; set; } + } + + public class ReplaceTotpResultData + { + /// The new recovery codes, shown once; every earlier code no longer works. + public List RecoveryCodes { get; set; } + + /// Always true: every session, this one too, has ended. + public bool SignInAgain { get; set; } + } + + /// Starts "I lost my authenticator": the sign-in in progress (mfa_transaction) and one recovery code. + public class BeginFactorRecoveryInput + { + public string Transaction { get; set; } + + public string Code { get; set; } + } + + /// The recovery secret from BeginFactorRecovery; keep it in memory only. + public class FactorRecoveryInput + { + public string Transaction { get; set; } + } + + public class CompleteFactorRecoveryInput : FactorRecoveryInput + { + /// A code from the new authenticator staged by PrepareReplacement. + public string Code { get; set; } + + /// Lost passkeys to remove, from the recovery status; others are kept. + public List RemovePasskeyIds { get; set; } + } + + public class FactorRecoveryStatusResult : StandardApiResponseV4Base + { + public FactorRecoveryStatusResultData Data { get; set; } + } + + public class FactorRecoveryStatusResultData + { + /// The recovery secret, returned once by BeginFactorRecovery; null otherwise. + public string Transaction { get; set; } + + /// pending or canceled. + public string State { get; set; } + + public int ExpiresIn { get; set; } + + /// prepare_replacement, complete and cancel. + public List NextActions { get; set; } + + /// The account's passkeys, to choose any that were lost. + public List Passkeys { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Authentication/LoginMfaModels.cs b/Web/Resgrid.Web.Services/Models/v4/Authentication/LoginMfaModels.cs new file mode 100644 index 000000000..175daaae5 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Authentication/LoginMfaModels.cs @@ -0,0 +1,85 @@ +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Services.Models.v4.Authentication +{ + /// + /// The login MFA transaction a password grant returned as mfa_transaction (workbook section 7.1). The secret is + /// the only authority these endpoints accept; keep it in memory and send it only in request bodies. + /// + public class LoginTransactionInput + { + public string Transaction { get; set; } + } + + /// A TOTP code or a recovery code for the transaction. + public class CompleteLoginCodeInput : LoginTransactionInput + { + public string Code { get; set; } + } + + public class CompleteLoginPasskeyInput : LoginTransactionInput + { + /// The request id from Authentication/PasskeyOptions. + public string RequestId { get; set; } + + /// The platform's assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + public JToken Credential { get; set; } + } + + /// A new authenticator key staged for the setup transaction; add it to an authenticator app, then send a code. + public class TotpSetupResult : StandardApiResponseV4Base + { + public TotpSetupResultData Data { get; set; } + } + + public class TotpSetupResultData + { + /// The key to type into an authenticator app, in groups of four. + public string SharedKey { get; set; } + + /// The otpauth:// URI to show as a QR code. + public string AuthenticatorUri { get; set; } + + public int ExpiresIn { get; set; } + } + + /// A Responder approval requested for the transaction (MfaApproval/Request with purpose login) and approved. + public class CompleteLoginApprovalInput : LoginTransactionInput + { + public string ApprovalRequestId { get; set; } + } + + /// Provider step-up for the transaction: the round trip from Sso/Begin (purpose step_up, same transaction). + public class CompleteLoginFederatedInput : LoginTransactionInput + { + public string SsoTransactionId { get; set; } + + /// The one-time sso_code the return target received. + public string SsoCode { get; set; } + + /// The PKCE verifier whose S256 challenge began the step-up. + public string CodeVerifier { get; set; } + } + + /// + /// The one-use completion code. Redeem it at /api/v4/connect/token with + /// grant_type=urn:resgrid:params:oauth:grant-type:mfa_completion, completion_code and transaction. + /// + public class LoginCompletionResult : StandardApiResponseV4Base + { + public LoginCompletionResultData Data { get; set; } + } + + public class LoginCompletionResultData + { + public string CompletionCode { get; set; } + + public int ExpiresIn { get; set; } + + /// True when a recovery code completed the sign-in: the session is recovery-classified; replace the lost factor. + public bool Recovery { get; set; } + + /// After CompleteTotpSetup: the new recovery codes, shown once. Null otherwise. + public System.Collections.Generic.List RecoveryCodes { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs index f538c6215..f66269667 100644 --- a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs +++ b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs @@ -8,6 +8,30 @@ public class VerifyStepUpInput public string Code { get; set; } } + /// A passkey assertion for DataProtection/VerifyPasskey (passkey plan section 8.1). + public class AdpPasskeyStepUpInput + { + /// The request id from DataProtection/PasskeyOptions. + public string RequestId { get; set; } + + /// The platform's assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + public Newtonsoft.Json.Linq.JToken Credential { get; set; } + } + + /// An approved MfaApproval/Request (purpose adp) to use once for a grant. + public class AdpApprovalStepUpInput + { + public string ApprovalRequestId { get; set; } + } + + /// A brokered provider step-up (Sso/Begin, purpose adp_step_up) to redeem once for a grant. + public class AdpFederatedStepUpInput + { + public string SsoTransactionId { get; set; } + public string SsoCode { get; set; } + public string CodeVerifier { get; set; } + } + /// /// Enrollment Wizard final-confirmation payload (ADP plan section 18.1 step 8). Everything here is /// re-validated server-side: caller must be the managing member, addon and global gate are diff --git a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs index 2ab8a8069..352d6eb4e 100644 --- a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs +++ b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs @@ -84,24 +84,46 @@ public class EnrollmentCommandResult : StandardApiResponseV4Base /// /// Result of a successful step-up verification. The window is ABSOLUTE (never sliding): clients /// conceal protected values at StepUpExpiresOnUtc and prompt again on the next reveal/edit. - /// When grant signing is configured on this deployment, GrantId/GrantToken carry a signed - /// Protected Data Grant the client presents alongside its access token on protected operations; - /// clients hold the token in MEMORY ONLY (never persisted) and discard it at expiry. On - /// deployments without signing key material both stay null and the verification itself remains - /// the capability (pre-broker behavior). + /// GrantId/GrantToken carry a signed Protected Data Grant the client presents alongside its access + /// token on protected operations; clients hold the token in MEMORY ONLY (never persisted) and discard + /// it at expiry. A deployment without signing key material answers 503 grants_not_configured + /// instead: a verification without a grant is not access (passkey plan section 8.1). /// public class StepUpResult : StandardApiResponseV4Base { - /// Unique grant id (jti) for display/audit correlation; null when grants are not configured. + /// Unique grant id (jti) for display/audit correlation. public string GrantId { get; set; } - /// Signed Protected Data Grant token; null when grants are not configured. MEMORY ONLY. + /// Signed Protected Data Grant token. MEMORY ONLY. public string GrantToken { get; set; } - /// Absolute UTC expiry of this step-up window (ISO 8601). + /// + /// The grant's absolute UTC expiry (ISO 8601): the verification time plus the department's window, never past the + /// session's end (passkey plan section 9.2). Conceal at this time; never add the window to the client's own clock. + /// public string StepUpExpiresOnUtc { get; set; } /// The department's effective step-up window in minutes. public int StepUpWindowMinutes { get; set; } } + /// + /// How the caller can verify for this department's protected data now (passkey plan section 7.5 rule 5): every usable + /// method is an equal choice, and is the one to show first. Advisory: every command rechecks. + /// + public class AdpStepUpMethodsResult : StandardApiResponseV4Base + { + public AdpStepUpMethodsResultData Data { get; set; } + } + + public class AdpStepUpMethodsResultData + { + /// Methods the caller has and the department accepts for protected data: totp, passkey, passkey_approval, federated. + public System.Collections.Generic.List Methods { get; set; } + + public string Preferred { get; set; } + + public System.Collections.Generic.List EnrolledMethods { get; set; } + + public System.Collections.Generic.List AllowedMethods { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Mfa/MfaStepUpModels.cs b/Web/Resgrid.Web.Services/Models/v4/Mfa/MfaStepUpModels.cs new file mode 100644 index 000000000..feae97b91 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Mfa/MfaStepUpModels.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Models.v4.Mfa +{ + /// + /// The second-factor choice for a named operation (passkey workbook section 7.2). Advisory: VerifyStepUp + /// re-checks everything. + /// + public class StepUpOptionsResult : StandardApiResponseV4Base + { + public StepUpOptionsResultData Data { get; set; } + } + + public class StepUpOptionsResultData + { + /// Methods the user can verify with now: enrolled and allowed. + public List Methods { get; set; } + + public List EnrolledMethods { get; set; } + + public List AllowedMethods { get; set; } + + /// The method to show first; null when nothing is usable. + public string Preferred { get; set; } + + /// True when the user must enroll a second factor before this operation. + public bool EnrollmentRequired { get; set; } + + /// How long one verification serves this operation. + public int WindowMinutes { get; set; } + + /// + /// Assertion options for this app's passkeys, when a passkey is usable for the operation; send its request id and + /// the platform's response to VerifyStepUp with method passkey. + /// + public PasskeyCeremonyResultData Passkey { get; set; } + } + + public class VerifyStepUpInput + { + /// One of security_change, adp_management, chat_export or account_security. + public string Operation { get; set; } + + /// totp (the default), passkey, passkey_approval (Responder approval) or federated (provider step-up). + public string Method { get; set; } + + /// The current authenticator code, for totp. + public string Code { get; set; } + + /// The request id from StepUpOptions, for passkey. + public string RequestId { get; set; } + + /// The platform's assertion (PublicKeyCredential.toJSON()), for passkey. + public JToken Credential { get; set; } + + /// For passkey_approval: the approved request from MfaApproval/Request (purpose step_up, same operation). + public string ApprovalRequestId { get; set; } + + /// For federated: the transaction from Sso/Begin (purpose step_up, same operation). + public string SsoTransactionId { get; set; } + + /// For federated: the one-time sso_code the return target received. + public string SsoCode { get; set; } + + /// For federated: the PKCE verifier whose challenge began the step-up. + public string CodeVerifier { get; set; } + } + + /// Evidence stays on the server; no token is returned (workbook section 7.2). + public class VerifyStepUpResult : StandardApiResponseV4Base + { + public VerifyStepUpResultData Data { get; set; } + } + + public class VerifyStepUpResultData + { + /// When the second factor was verified (ISO 8601 UTC). + public string VerifiedAt { get; set; } + + /// When this verification stops serving the operation (ISO 8601 UTC). + public string ExpiresAt { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/MfaApproval/MfaApprovalModels.cs b/Web/Resgrid.Web.Services/Models/v4/MfaApproval/MfaApprovalModels.cs new file mode 100644 index 000000000..2b2ff05d9 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/MfaApproval/MfaApprovalModels.cs @@ -0,0 +1,150 @@ +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Services.Models.v4.MfaApproval +{ + /// + /// Asks the user's Responder to approve (workbook section 7.4). Send only after the user chose "Approve with Responder"; + /// never automatically. + /// + public class MfaApprovalRequestInput + { + /// login (with ) or step_up (the signed-in session, with ). + public string Purpose { get; set; } + + /// For step_up: adp_management or chat_export. Security changes and account factors never accept approval. + public string Operation { get; set; } + + /// For login: the login MFA transaction secret. + public string Transaction { get; set; } + } + + public class MfaApprovalRequestResult : StandardApiResponseV4Base + { + public MfaApprovalRequestResultData Data { get; set; } + } + + public class MfaApprovalRequestResultData + { + public string ApprovalRequestId { get; set; } + + /// Show this on the requesting screen only; the user types it in Responder. It is never pushed. + public string MatchNumber { get; set; } + + public int ExpiresIn { get; set; } + } + + /// A request the caller made: by its login transaction secret, or by the signed-in session when absent. + public class MfaApprovalReferenceInput + { + public string ApprovalRequestId { get; set; } + + public string Transaction { get; set; } + } + + public class MfaApprovalStatusResult : StandardApiResponseV4Base + { + public MfaApprovalStatusResultData Data { get; set; } + } + + public class MfaApprovalStatusResultData + { + /// pending, approved, denied, expired, canceled or consumed. Poll every 2 seconds. + public string State { get; set; } + + public string ExpiresAt { get; set; } + } + + public class MfaApprovalPendingResult : StandardApiResponseV4Base + { + /// Null when nothing is waiting. + public MfaApprovalPendingResultData Data { get; set; } + } + + /// What Responder shows before Approve and Deny. The number is not here: the user reads it from the requesting screen. + public class MfaApprovalPendingResultData + { + public string ApprovalRequestId { get; set; } + + /// web, unit, dispatch or ic. + public string RequestingApp { get; set; } + + public string InstallationLabel { get; set; } + + public bool Shared { get; set; } + + public string Department { get; set; } + + /// login or step_up. + public string Purpose { get; set; } + + public string Operation { get; set; } + + /// Region and country only. + public string OriginRegion { get; set; } + + public string CreatedAt { get; set; } + + public string ExpiresAt { get; set; } + + public int AttemptsRemaining { get; set; } + } + + public class MfaApprovalOptionsInput + { + public string ApprovalRequestId { get; set; } + } + + public class MfaApprovalApproveInput + { + public string ApprovalRequestId { get; set; } + + /// The two digits the user read from the requesting screen. + public string MatchNumber { get; set; } + + /// The request id from MfaApproval/Options. + public string RequestId { get; set; } + + /// The Responder passkey assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + public JToken Credential { get; set; } + } + + public class DisableApprovalInstallationsInput + { + /// The installation to stop, from AccountSecurity/Methods. Leave empty with . + public string InstallationId { get; set; } + + /// Stop every installation and turn approval off on every Responder passkey. + public bool All { get; set; } + } + + public class DisableApprovalInstallationsResult : StandardApiResponseV4Base + { + public DisableApprovalInstallationsResultData Data { get; set; } + } + + public class DisableApprovalInstallationsResultData + { + /// Zero when nothing was taking requests (already stopped, or not this account's installation). + public int InstallationsStopped { get; set; } + + public int PasskeysStopped { get; set; } + } + + public class MfaApprovalDenyInput + { + public string ApprovalRequestId { get; set; } + + /// declined, or not_me ("I didn't request this": ends that sign-in and suspends approval requests). + public string Reason { get; set; } + } + + public class MfaApprovalDecisionResult : StandardApiResponseV4Base + { + public MfaApprovalDecisionResultData Data { get; set; } + } + + public class MfaApprovalDecisionResultData + { + public string State { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Passkeys/PasskeyModels.cs b/Web/Resgrid.Web.Services/Models/v4/Passkeys/PasskeyModels.cs new file mode 100644 index 000000000..a387da2ca --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Passkeys/PasskeyModels.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Services.Models.v4.Passkeys +{ + /// A started WebAuthn ceremony (workbook section 7.1): the request id to send back and the options for the platform. + public class PasskeyCeremonyResult : StandardApiResponseV4Base + { + public PasskeyCeremonyResultData Data { get; set; } + } + + public class PasskeyCeremonyResultData + { + public string RequestId { get; set; } + + /// WebAuthn PublicKeyCredentialCreationOptions (registration) or PublicKeyCredentialRequestOptions (assertion) JSON. + public JRaw Options { get; set; } + } + + public class CompletePasskeyRegistrationInput + { + public string RequestId { get; set; } + + /// The platform's response: PublicKeyCredential.toJSON(), as an object or a JSON string. + public JToken Credential { get; set; } + + /// Optional name for the passkey; at most 100 characters. A default naming the app is used when empty. + public string DisplayName { get; set; } + } + + public class PasskeyResult : StandardApiResponseV4Base + { + public PasskeyResultData Data { get; set; } + } + + /// + /// One passkey in the user's own inventory (plan section 6.5). Registration context, attachment and backup state are + /// server-observed or client-reported hints, not proof of a device. + /// + public class PasskeyResultData + { + public string PasskeyId { get; set; } + public string DisplayName { get; set; } + + /// The app the passkey works in: web, responder, unit, dispatch or ic. + public string Client { get; set; } + + public string CreatedOn { get; set; } + public string CreatedPlatform { get; set; } + public string CreatedInstallation { get; set; } + public string CreatedUserAgentFamily { get; set; } + public bool CreatedOnSharedInstallation { get; set; } + public string Attachment { get; set; } + public bool BackupEligible { get; set; } + public bool BackedUp { get; set; } + public string LastUsedOn { get; set; } + public string LastUsedClient { get; set; } + public string LastUsedInstallation { get; set; } + + /// Responder passkeys only: whether it may approve other apps' requests. Null for other apps. + public bool? ApprovalEnabled { get; set; } + } + + public class PasskeyListResult : StandardApiResponseV4Base + { + public List Data { get; set; } + } + + public class RenamePasskeyInput + { + public string PasskeyId { get; set; } + public string DisplayName { get; set; } + } + + public class RevokePasskeyInput + { + public string PasskeyId { get; set; } + } + + public class RevokeAllPasskeysForClientInput + { + /// web, responder, unit, dispatch or ic. + public string Client { get; set; } + } + + public class SetPasskeyApprovalInput + { + public string PasskeyId { get; set; } + public bool Enabled { get; set; } + } + + public class PasskeyChangeResult : StandardApiResponseV4Base + { + public PasskeyChangeResultData Data { get; set; } + } + + public class PasskeyChangeResultData + { + /// How many passkeys the change revoked (0 for a rename or approval change). + public int Revoked { get; set; } + + /// How many sessions that signed in with a removed passkey were ended. + public int SessionsEnded { get; set; } + + /// True when this session was one of them: the client signs in again. + public bool CurrentSessionEnded { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs index 81c64f2c6..c2f392c9a 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Search/SearchApiModels.cs @@ -19,6 +19,9 @@ public class SearchResultData public string DegradedReason { get; set; } + /// True while the department's index is queued for, or in the middle of, a build: results may be incomplete. + public bool IndexBuilding { get; set; } + public List Results { get; set; } = new List(); public List Actions { get; set; } = new List(); @@ -43,6 +46,8 @@ public class SearchHitData public DateTime? OccurredOn { get; set; } public string Category { get; set; } public string Status { get; set; } + /// Plain-text excerpt of the indexed text around the first match (full search only), e.g. the call note that matched. + public string Snippet { get; set; } public Dictionary Metadata { get; set; } = new Dictionary(); } diff --git a/Web/Resgrid.Web.Services/Models/v4/Sessions/SharedSessionModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sessions/SharedSessionModels.cs new file mode 100644 index 000000000..3dde48f8e --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Sessions/SharedSessionModels.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Resgrid.Web.Services.Models.v4.Passkeys; + +namespace Resgrid.Web.Services.Models.v4.Sessions +{ + /// The caller's own session state (passkey plan section 12.5). A locked shared session may read it. + public class CurrentSessionResult : StandardApiResponseV4Base + { + public CurrentSessionResultData Data { get; set; } + } + + public class CurrentSessionResultData + { + public string Operator { get; set; } + + /// The app the session belongs to: web, responder, unit, dispatch or command. + public string Client { get; set; } + + public bool Shared { get; set; } + + public bool Locked { get; set; } + + /// Send it back to unlock; it changes with every lock. + public long LockVersion { get; set; } + + /// explicit or idle while locked. + public string LockReason { get; set; } + + public int IdleLockMinutes { get; set; } + + /// When the session locks unless the operator does something (ISO 8601 UTC); null while locked. + public string IdleLocksAt { get; set; } + + /// When the shift ends whatever happens (ISO 8601 UTC). Warn before it. + public string ShiftEndsAt { get; set; } + + public string InstallationLabel { get; set; } + } + + public class SessionLockResult : StandardApiResponseV4Base + { + public SessionLockResultData Data { get; set; } + } + + public class SessionLockResultData + { + public bool Locked { get; set; } + + public long LockVersion { get; set; } + } + + public class EndShiftInput + { + /// True for Switch operator: the same as ending the shift, audited as a handoff. + public bool SwitchOperator { get; set; } + } + + public class EndShiftResult : StandardApiResponseV4Base + { + public EndShiftResultData Data { get; set; } + } + + public class EndShiftResultData + { + /// Always true: discard this session's tokens and caches, then sign the next operator in normally. + public bool Ended { get; set; } + } + + /// How the locked session's operator can unlock it (plan section 12.5.3). Nothing verifies here. + public class UnlockOptionsInput + { + /// The lock version the client last saw; a newer lock means a newer screen. + public long LockVersion { get; set; } + } + + public class UnlockOptionsResult : StandardApiResponseV4Base + { + public UnlockOptionsResultData Data { get; set; } + } + + public class UnlockOptionsResultData + { + public string Operator { get; set; } + + public long LockVersion { get; set; } + + /// + /// What this operator can unlock with here: totp, passkey, passkey_approval and federated + /// (the identity provider's MFA, begun with unlock-sso). Empty means quick unlock is unavailable; end the shift + /// and sign in normally. + /// + public List Methods { get; set; } + + /// The method to offer first. Shared installations never start the passkey prompt on their own. + public string Preferred { get; set; } + + /// Assertion options bound to this lock, when passkey is offered. + public PasskeyCeremonyResultData Passkey { get; set; } + } + + public class UnlockApprovalInput + { + public long LockVersion { get; set; } + } + + public class CompleteUnlockInput + { + /// The lock version from unlock-options. A lock since then refuses the unlock. + public long LockVersion { get; set; } + + /// totp, passkey, passkey_approval or federated. + public string Method { get; set; } + + /// For totp: the current authenticator code. + public string Code { get; set; } + + /// For passkey: the request id from unlock-options. + public string RequestId { get; set; } + + /// For passkey: the platform's assertion (PublicKeyCredential.toJSON()). + public JToken Credential { get; set; } + + /// For passkey_approval: the approved request from unlock-approval. + public string ApprovalRequestId { get; set; } + + /// For federated: the transaction from unlock-sso. + public string SsoTransactionId { get; set; } + + /// For federated: the one-time sso_code the return target received. + public string SsoCode { get; set; } + + /// For federated: the PKCE verifier whose challenge began the unlock. + public string CodeVerifier { get; set; } + } + + /// Begins an unlock through the department's identity provider with MFA (provider step-up, plan section 7.8). + public class UnlockSsoInput + { + public long LockVersion { get; set; } + + /// ios, android, web or desktop. + public string Platform { get; set; } + + /// This app's registered return target. + public string ReturnTarget { get; set; } + + /// Opaque client state returned with the code. + public string State { get; set; } + + /// S256 PKCE challenge; the verifier goes to complete-unlock. + public string CodeChallenge { get; set; } + + public string CodeChallengeMethod { get; set; } + } + + public class CompleteUnlockResult : StandardApiResponseV4Base + { + public CurrentSessionResultData Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Sso/BrokeredSsoModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sso/BrokeredSsoModels.cs new file mode 100644 index 000000000..8d424e2b7 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Sso/BrokeredSsoModels.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Services.Models.v4.Sso +{ + /// + /// Starts a server-brokered SSO sign-in (workbook section 7.3). Name the department with one of the three + /// identifiers. The client keeps its PKCE verifier in memory and sends only the S256 challenge. + /// + public class SsoBeginInput + { + public string DepartmentToken { get; set; } + public string DepartmentCode { get; set; } + public string Username { get; set; } + + /// web, responder, unit, dispatch or ic; defaults to the X-Resgrid-Client header. + public string ClientApp { get; set; } + + /// ios, android, web or electron; a label only. + public string Platform { get; set; } + + /// Where the one-time code is sent: a return target registered for this client, matched exactly. + public string ReturnTarget { get; set; } + + /// The client's CSRF value, echoed back unchanged on return. + public string State { get; set; } + + public string CodeChallenge { get; set; } + + /// Only S256. + public string CodeChallengeMethod { get; set; } + + /// + /// login (default), reauth (fresh proof for the signed-in session), or step_up (provider MFA: + /// for the signed-in session's , or to complete the password sign-in in ). + /// + public string Purpose { get; set; } + + /// For a session step_up: the operation it serves (security_change, adp_management, chat_export). + public string Operation { get; set; } + + /// + /// For a step_up that completes a password sign-in: the login MFA transaction secret. Redeem the step-up's code + /// at Authentication/CompleteFederated. + /// + public string Transaction { get; set; } + } + + public class SsoBeginResult : StandardApiResponseV4Base + { + public SsoBeginResultData Data { get; set; } + } + + public class SsoBeginResultData + { + /// Open this in the platform's authentication session, the system browser, or a top-level redirect. + public string AuthorizeUrl { get; set; } + + public string SsoTransactionId { get; set; } + + public int ExpiresIn { get; set; } + } + + public class SsoRedeemInput + { + public string SsoTransactionId { get; set; } + + /// The one-time sso_code from the return target. + public string SsoCode { get; set; } + + /// The PKCE verifier whose S256 challenge began the sign-in. + public string CodeVerifier { get; set; } + + /// Optional OAuth client_id, as the password grant takes it (refresh-token lifetime). + public string ClientId { get; set; } + } + + public class SsoRedeemResult : StandardApiResponseV4Base + { + public SsoRedeemResultData Data { get; set; } + } + + /// + /// mfa_required: complete the second factor through Authentication/* with . + /// completed: no second factor is required; redeem and + /// with the mfa_completion grant. reauthenticated: the session has fresh SSO proof. + /// + public class SsoRedeemResultData + { + public string Outcome { get; set; } + + /// The login MFA transaction secret (for mfa_required and completed). + public string Transaction { get; set; } + + public int ExpiresIn { get; set; } + + public List MfaMethods { get; set; } + + public List MfaEnrolled { get; set; } + + public string MfaPreferred { get; set; } + + public string CompletionCode { get; set; } + + /// For completed: federated when the provider's MFA satisfied the sign-in; null when none was needed. + public string MfaSatisfiedBy { get; set; } + + public string VerifiedAt { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Sso/FederatedMfaModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sso/FederatedMfaModels.cs new file mode 100644 index 000000000..38178e9c0 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Sso/FederatedMfaModels.cs @@ -0,0 +1,78 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace Resgrid.Web.Services.Models.v4.Sso +{ + /// + /// The department's provider step-up mapping (passkey plan section 7.8) on its active SSO configuration, and whether it + /// is effective: a mapping counts only after a successful test at its current version. + /// + public class FederatedMfaMappingResult : StandardApiResponseV4Base + { + public FederatedMfaMappingResultData Data { get; set; } + } + + public class FederatedMfaMappingResultData + { + public string DepartmentSsoConfigId { get; set; } + + /// oidc or saml2: which mapping fields apply. + public string ProviderType { get; set; } + + /// + /// The mapping: requestAcrValues, requestClaims (OIDC), requestAuthnContextClassRefs (SAML), and + /// what counts as MFA: acceptAmr, acceptAcr, acceptAcrs (OIDC) or acceptAuthnContextClassRefs + /// (SAML). Null when none is set. + /// + public JToken Mapping { get; set; } + + public long MappingVersion { get; set; } + + public long? TestedVersion { get; set; } + + public DateTime? TestedOn { get; set; } + + public string TestedByUserId { get; set; } + + /// True when the mapping passed its test at the current version and so can be used. + public bool Effective { get; set; } + } + + public class SaveFederatedMfaMappingInput + { + /// The mapping object (see ), or null to remove it. + public JToken Mapping { get; set; } + } + + /// Starts the managing member's test step-up with the saved mapping, through brokered SSO. + public class FederatedMfaTestBeginInput + { + public string ReturnTarget { get; set; } + public string State { get; set; } + public string CodeChallenge { get; set; } + public string CodeChallengeMethod { get; set; } + public string Platform { get; set; } + } + + public class FederatedMfaTestCompleteInput + { + public string SsoTransactionId { get; set; } + public string SsoCode { get; set; } + public string CodeVerifier { get; set; } + } + + public class FederatedMfaTestResult : StandardApiResponseV4Base + { + public FederatedMfaTestResultData Data { get; set; } + } + + public class FederatedMfaTestResultData + { + public bool Tested { get; set; } + + /// The returned value the mapping counted as MFA, as kind:value. + public string MatchedValue { get; set; } + + public long MappingVersion { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Sso/GetDepartmentSsoConfigResult.cs b/Web/Resgrid.Web.Services/Models/v4/Sso/GetDepartmentSsoConfigResult.cs index 74661abf3..9f9354776 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Sso/GetDepartmentSsoConfigResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Sso/GetDepartmentSsoConfigResult.cs @@ -39,6 +39,14 @@ public class GetDepartmentSsoConfigResultData /// SAML entity ID / service-provider identifier (SAML only). public string EntityId { get; set; } + /// + /// Where an app opens a legacy (unbrokered) SAML sign-in, with RelayState=<app>.<nonce> added: this + /// server's start page, which sends the browser to the department's IdP with an AuthnRequest. The response returns to + /// the app through connect/saml-mobile-callback. SAML only, and only when the department's configuration can + /// start one (its IdP SSO URL, entity ID, ACS URL and IdP certificate). + /// + public string SamlLoginUrl { get; set; } + /// Whether local username/password login is permitted in addition to SSO. public bool AllowLocalLogin { get; set; } @@ -49,8 +57,9 @@ public class GetDepartmentSsoConfigResultData public bool RequireMfa { get; set; } /// - /// The redirect URI the mobile app must use for the OIDC authorization-code flow. - /// Format: resgrid://auth/callback + /// The redirect URI the calling app uses for the legacy (unbrokered) OIDC authorization-code flow: its own scheme, by + /// the app named in X-Resgrid-Client (for example resgridunit://auth/callback). A caller that names no + /// app gets Responder's resgrid://auth/callback. /// public string OidcRedirectUri { get; set; } @@ -59,6 +68,18 @@ public class GetDepartmentSsoConfigResultData /// e.g. "openid email profile offline_access" /// public string OidcScopes { get; set; } + + /// The department, for clients that must name it again (for example Dispatch's department selection). + public int? DepartmentId { get; set; } + + /// + /// Opaque, system-encrypted department token: send it as department_token (legacy external-token) or + /// DepartmentToken (Sso/Begin) instead of a department code or username. + /// + public string DepartmentToken { get; set; } + + /// True when this department can sign in through server-brokered SSO (Sso/Begin). + public bool BrokeredSsoAvailable { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Sso/SsoAdminModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sso/SsoAdminModels.cs index e22e1d428..4a213cd18 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Sso/SsoAdminModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Sso/SsoAdminModels.cs @@ -112,6 +112,22 @@ public class SsoConfigDetailData : SsoConfigSummaryData /// SAML Assertion Consumer Service URL. public string AssertionConsumerServiceUrl { get; set; } + /// The IdP's SAML single sign-on URL that brokered sign-in sends its AuthnRequest to. + public string IdpSsoUrl { get; set; } + + /// + /// The OIDC redirect URI to register with the IdP for brokered sign-in (plan section 7.7.2 item 7). It is added + /// alongside any existing registration, which older app builds keep using. + /// + public string OidcBrokerRedirectUri { get; set; } + + /// + /// Each app's own redirect URIs for sign-in the app runs itself (older app versions, or while brokered sign-in is off): + /// its native scheme, and its web edition's page where this deployment serves one. The department registers every one + /// with its IdP; an app can only receive a redirect on an address it owns. OIDC only. + /// + public List OidcAppRedirectUris { get; set; } + /// /// JSON attribute mapping from IdP claim names to Resgrid user fields. /// Example: {"email":"http://schemas/.../emailaddress","firstName":"given_name","lastName":"family_name"} @@ -134,6 +150,20 @@ public class SsoConfigDetailData : SsoConfigSummaryData public bool HasScimBearerToken { get; set; } } + /// One of an app's legacy OIDC redirect URIs. + public class SsoAppRedirectUriData + { + /// The app, as X-Resgrid-Client names it: responder, unit, dispatch or ic. + public string Client { get; set; } + + public string DisplayName { get; set; } + + /// True for the app's web edition (a page on its web host), false for the native app's own scheme. + public bool Web { get; set; } + + public string RedirectUri { get; set; } + } + /// Department security policy data. public class SecurityPolicyData { @@ -167,6 +197,39 @@ public class SecurityPolicyData /// Data classification level: 0=Unclassified, 1=CUI, 2=Confidential. public int DataClassificationLevel { get; set; } + /// Accept a passkey as sign-in and step-up MFA. + public bool AllowPasskeysForLoginMfa { get; set; } + + /// Accept a passkey for protected data. + public bool AllowPasskeysForAdp { get; set; } + + /// Accept provider step-up for sign-in and step-up MFA. + public bool AllowFederatedMfaForLoginMfa { get; set; } + + /// Accept provider step-up for protected data. + public bool AllowFederatedMfaForAdp { get; set; } + + /// Accept Responder approval. + public bool AllowResponderApproval { get; set; } + + /// Let recent sign-in MFA serve protected data. + public bool AcceptRecentLoginMfaForAdp { get; set; } + + /// Let fresh unlock MFA serve protected data. + public bool AcceptRecentUnlockMfaForAdp { get; set; } + + /// Minutes of no operator activity before a shared session locks. + public int SharedIdleLockMinutes { get; set; } + + /// Hours after sign-in when a shared session ends. + public int SharedShiftHours { get; set; } + + /// Apps whose sessions are always shared (flags: Unit 1, IC 2, Dispatch 4). + public int SharedModeRequiredApps { get; set; } + + /// Advanced by the server whenever the sign-in MFA rules change; read-only. + public long MfaPolicyVersion { get; set; } + /// Date/time the policy was created. public DateTime CreatedOn { get; set; } @@ -211,6 +274,9 @@ public class SaveSsoConfigInput /// SAML Assertion Consumer Service URL. public string AssertionConsumerServiceUrl { get; set; } + /// The IdP's SAML single sign-on URL (HTTP-Redirect binding) for brokered sign-in. Optional; https. + public string IdpSsoUrl { get; set; } + /// /// IdP public certificate in PEM format (plaintext). Encrypted before storage. /// Omit or send null to leave an existing certificate unchanged. @@ -275,6 +341,42 @@ public class SaveSecurityPolicyInput /// Data classification level: 0=Unclassified, 1=CUI, 2=Confidential. [Range(0, 2)] public int DataClassificationLevel { get; set; } + + // Second-factor method switches (passkey plan section 10.1). Null keeps the stored value, so a client that does not + // know these fields never switches anything off. Changing any of them requires the managing member. + + /// Accept a passkey bound to the requesting app as sign-in and step-up MFA. TOTP is always accepted. + public bool? AllowPasskeysForLoginMfa { get; set; } + + /// Accept a passkey for Protected Data Grants, ADP management and protected workflows. + public bool? AllowPasskeysForAdp { get; set; } + + /// Accept provider step-up for sign-in and step-up MFA (needs a tested mapping). + public bool? AllowFederatedMfaForLoginMfa { get; set; } + + /// Accept provider step-up for protected data (needs a tested mapping). + public bool? AllowFederatedMfaForAdp { get; set; } + + /// Accept Responder approval where the matching passkey switch is on. + public bool? AllowResponderApproval { get; set; } + + /// Let recent same-session sign-in MFA serve protected data without another verification. + public bool? AcceptRecentLoginMfaForAdp { get; set; } + + /// Let fresh same-operator unlock MFA serve protected data on a shared session. + public bool? AcceptRecentUnlockMfaForAdp { get; set; } + + /// Shared sessions lock after this many idle minutes (1-15). Managing member only; omitted leaves it unchanged. + public int? SharedIdleLockMinutes { get; set; } + + /// Shared sessions end this many hours after sign-in (1-24). Managing member only. + public int? SharedShiftHours { get; set; } + + /// + /// The apps whose sessions are always shared: flags Unit 1, IC 2, Dispatch 4. Sign-ins that do not name their app count + /// too. Managing member only; a new app needs the deployment's shared-device mode. + /// + public int? SharedModeRequiredApps { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj index 9e15446cc..2759af782 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj @@ -132,6 +132,7 @@ + diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 5224dd162..214af0c8d 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -75,6 +75,54 @@ Text-To-Call has always done it. + + + The account "Sign-in methods" view (passkey plan section 6.5): the signed-in user's own authenticator app and + passkeys, grouped by the app each passkey works in. Viewing needs only a signed-in session; every change goes + through a command that checks fresh MFA. + + + + + "This wasn't me" (plan section 6.5): marks a verification as not the account holder's, ends the session it opened or + served (never the one reporting it) and sends a security notice. Its effect is ending a session, which any session of + the account may already do, so it needs no fresh MFA: a user whose factor was taken can still report. + + + + + The user's Responder installations that can receive approval requests (plan section 6.5): active, personal (never + shared) Responder sessions. Push delivery is per account, not per installation, so no push state is shown. + + + + The user's department identity-provider links; informational, managed by the department and SCIM. + + + + Confirms the password for this session: fresh first-factor evidence for operations that need it (adding a passkey, + replacing the authenticator). Failures count toward the account lockout. SSO accounts reauthenticate through + Sso/Begin with purpose reauth. + + + + + Stages a new authenticator key. Needs the password (or SSO) confirmed within five minutes and the current + authenticator or a passkey for this app verified within five minutes (plan section 7.6 row 14). + + + + + Makes the staged key the authenticator once its code verifies. Everything the old one authorized ends: every + session (this one too), all evidence, and the old recovery codes. The new codes are returned once. + + + + + The authority to replace the authenticator: this session's first factor within , + and TOTP or a passkey for this app within five minutes. Never approval or provider step-up (plan section 7.6 row 14). + + Deterministic department setup and configuration guidance. Source configuration remains read-only; Ask is separately gated. @@ -204,6 +252,65 @@ Only an owned conversation identifier and expected revision are accepted. + + + Second-factor completion for a login MFA transaction (passkey plan sections 7.2 and 7.5 rule 3; workbook section + 7.1). The transaction secret from the password grant is the only authority here: no password or IdP token is + resent, and nothing here issues a token. TOTP, a passkey bound to the signing-in app, or a recovery code completes it + once; failures of any method share the transaction's attempt limit and the account lockout (section 7.5 rule 6). + + + + Completes the sign-in with the current authenticator code. + + + Assertion options for the user's passkeys bound to the signing-in app, bound to this transaction. + + + Completes the sign-in with a passkey assertion for the request from . + + + + Completes the sign-in with provider step-up (plan sections 7.6 row 3 and 7.8): the brokered round trip begun + through Sso/Begin with purpose step_up and this transaction, redeemed once here with its one-time code + and PKCE verifier. The provider must have signed in this account after the step-up began, with a value the + department's tested mapping counts as MFA; the evidence carries the provider's authentication time. + + + + + Completes the sign-in with a Responder approval (plan section 7.9 step 6): the request made for this transaction + through MfaApproval/Request and approved in the user's Responder. It is used once, while the approving + passkey and Responder session still count; the evidence names them and carries the approval time. + + + + + Completes the sign-in with a one-time recovery code. The session is recovery-classified: it never satisfies a + later MFA check or ADP, and the user should replace the lost factor (plan sections 6.1 item 10 and 6.3). + + + + + For a sign-in that must have MFA the account does not have yet (mfa_setup_transaction): stages a new + authenticator key. The transaction permits only this setup; it grants nothing until a code from the new key verifies. + + + + + Turns the new authenticator on once its code verifies, and completes the sign-in with it: the completion code + redeems at the token endpoint like any other, and the recovery codes are returned once. + + + + A pending transaction whose account has no authenticator yet: the only thing it may do is set one up. + + + + The pending transaction for this client and its user, or the refusal: an unusable transaction, a locked account, + a method the department does not accept, or TOTP-based methods (TOTP and recovery codes) for an account without TOTP. + + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -1208,11 +1315,14 @@ The caller's current authenticator (TOTP) code ChatActionResult indicating whether the step-up succeeded - + Resolves the current user and enforces the export MFA-enrollment precondition shared by the verify and gate paths. On success returns the user with a null error; otherwise returns a null user and the HTTP result to return: 401 when the principal can't be resolved, 403 when 2FA is not enrolled. + With , an SSO member whose department accepts its identity provider's MFA + for sign-in counts as enrolled without a Resgrid factor (passkey plan section 7.6 row 8, section 7.8): they verify + through Mfa/VerifyStepUp. The TOTP-only VerifyExportMfa still needs TOTP; passkeys and approval need it anyway. @@ -1509,12 +1619,56 @@ Service to generate an authentication token that is required to communicate with all other v4 services + + Where a member whose department requires MFA sets up an authenticator (plan section 7.6 rollout). + Generates a token that is then used for subsquent requests to the API. ValidateResult object, with IsValid set if the settings are correct + + + Starts the restricted login transaction after a verified password (workbook section 7.1): no tokens, only the + transaction secret and the methods it accepts. Null when it could not start, so the caller falls back to the + legacy totp_code response, which still requires the second factor. + + + + + A setup transaction (plan section 6.2): a login transaction for an account with no factor, which can only set up an + authenticator and then complete with it. Null when it could not start; the enrollment error stands on its own. + + + + + Redeems a login transaction's one-use completion code for the normal token response (workbook section 7.1). The + account, department policy and first-factor rules are rechecked; the session records the original first-factor + time and the second factor actually verified. A lost response means signing in again: nothing is issued twice. + + + + + The session's first-factor method for an SSO login, from the department configuration that authenticated it; + when that configuration is gone or disabled. + + + + The scopes a password sign-in grants, limited to what the client asked for. + + + + Whether a password may sign this user in to the department: no SSO-only account link, and the department does + not require SSO. Checked at the first factor and again when a login transaction is redeemed. + + + + + The discovery fields every client needs to continue without a department code (plan section 7.7.4): the + system-encrypted department token, the department id, and whether brokered SSO can run for this department. + + Returns the SSO configuration for a department so the mobile app can determine @@ -1552,16 +1706,60 @@ in addition to the IdP token. - + SAML 2.0 Assertion Consumer Service (ACS) relay for mobile apps. - Receives the SAMLResponse POST from the IdP, then redirects to the - resgrid:// deep-link scheme so the mobile app can complete authentication - via the external-token endpoint. + Receives the SAMLResponse POST from the IdP, then redirects to the app that started the sign-in + (named in its RelayState, see ) so it can complete authentication + via the external-token endpoint. An untagged RelayState returns to Responder, as before. Configure your IdP's ACS URL to point here: POST /api/v4/connect/saml-mobile-callback?departmentCode=DEPT + + + The IdP's OIDC redirect for brokered SSO (passkey plan section 7.7.2 step 2). Departments register + {api}/api/v4/connect/oidc-callback with their IdP. The server exchanges the code and validates the id_token + (state, nonce, PKCE), then sends the browser to the client's registered return target with a one-time + sso_code. No token or assertion is ever in the URL. + + + + + Redirects to the registered return target when the transaction is known; otherwise there is nowhere trusted to + send the browser, so the callback answers with the error itself. + + + + + Starts a legacy (unbrokered) SAML sign-in for an app (workbook section 12, slice 35). An app cannot build a SAML + AuthnRequest, so it opens this page, which sends the browser to the department's IdP with one. The RelayState must be + the app's own tagged value (unit.<nonce>): the IdP returns it with the response, and the relay + () sends the member back to that app and echoes it. Anything else is refused, so + this page never names another destination. Nothing is stored here. + + + + How long ago the provider may have authenticated a shared installation's member (the reauthentication window). + + + + A sign-in from a shared installation: it says so, or the department makes this app's sessions shared (the broker's + rule for its own round trips). + + + + + This server's legacy SAML start page for the department, when its configuration can start a sign-in. Discovery calls + it only for a SAML configuration. + + + + + The legacy OIDC redirect URI for the app asking (X-Resgrid-Client): each app has its own scheme, and the + department's IdP must list each one. A caller that names no app gets Responder's, as every caller did before. + + Decrypts a departmentToken (format: {departmentId}:{departmentCode}) produced by the @@ -1569,6 +1767,34 @@ Falls back to a plain departmentCode name-lookup when departmentToken is absent. + + The 64 hex digits of a relay token, or null when it is not one. + + + What a relayed SAML exchange that stopped at the member's Resgrid code validated, for the code retry. + + + What a relayed SAML exchange that stopped at the member's Resgrid code validated, for the code retry. + + + + Keeps what the first exchange validated under its relay token, encrypted, for the relay's own lifetime (not sliding), + so the member's code retry can finish the sign-in the spent assertion can no longer start again. + + + + A wrong code on a relayed SAML sign-in; the retry state ends after the transaction attempt limit. + + + The one retry that finishes the sign-in: atomic, so racing retries cannot both sign in. + + + + Records what this sign-in verified as server-side evidence for the new API session (passkey plan section 5.3), so + a TOTP sign-in satisfies a following step-up window the same way it does on Web. A failure here never blocks the + sign-in; the user is simply asked to verify again for a sensitive operation. + + Adds all Resgrid resource claims (View, Create, Update, Delete) to the given identity. @@ -1804,6 +2030,43 @@ epoch, still expires, and still authorizes an audited read. + + + A grant from this session's recent sign-in or shared-unlock MFA, with no new prompt (plan sections 7.6 row 9 and 9.1): only + where the department accepts reusing that method for protected data, and only until the original verification's window + ends. step_up_required when nothing qualifies; the client then verifies with VerifyStepUp or another method. + + + + + How the caller can verify for this department's protected data now: the methods it has that the department + accepts, and which to show first (passkey plan section 7.5 rule 5). Advisory; every verification rechecks. + + + + + Assertion options for a passkey bound to this app, for this department's protected data (passkey plan section 8.1). + Needs a tracked session; the department and deployment must accept passkeys for protected data. + + + + Verifies the passkey assertion and returns a passkey grant (the same as a code). + + + + Uses an approved Responder request (MfaApproval/Request, purpose adp, from this session and department) + once, and returns a passkey_approval grant. Answers 409 approval_pending until it is decided. + + + + + Redeems a brokered provider step-up (Sso/Begin, purpose adp_step_up) once and returns a federated + grant, where the department accepts its provider's MFA for protected data (passkey plan section 7.8). + + + + The caller as the grant issuer sees it: this user, department and validated session (never client-supplied). + Queues enrollment (Disabled -> EnrollmentQueued). Managing member only; requires an active @@ -1826,13 +2089,13 @@ - MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the - caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after - fresh TOTP, absolute lifetime = the department step-up window — in the - X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy - epoch. On deployments without grant key material (CanValidateGrants false) the gate is - inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. - Returns null when the command may proceed. + MFA-recency gate for enrollment, offboarding and security commands (ADP plan sections 3.5 and 18, passkey plan + section 7.6 row 10 and section 8.4): actual MFA within the sensitive-operation window (5 minutes), shown either + by Mfa/VerifyStepUp evidence on this session (operation adp_management) or by a Protected Data Grant in + the X-Resgrid-Protected-Grant header whose own verification is that recent, bound to THIS user, session and + department at the CURRENT policy epoch. The department's longer data-access window never stretches this: an + eight-hour-old grant cannot change the protection lifecycle. Missing grant key material is not proof either; + the evidence path still works without it. Returns null when the command may proceed. A step-up-EXEMPT grant is refused here. Those are minted by RequestGrant without any second factor, for a client the department exempted from the reveal prompt (plan 3.3) — that @@ -2010,6 +2273,36 @@ Array of CallTemplateResult objects for each role in the department + + + "I lost my authenticator" (passkey plan sections 5.4 and 6.3). A sign-in's verified first factor (its login + transaction) and a recovery code open a restricted recovery; its secret permits only status, staging a new + authenticator, completing and canceling. Completion replaces the authenticator, rotates the recovery codes, removes + the lost passkeys the user chose, and ends every session; the user then signs in normally. Nothing here issues a token. + + + + + Opens a recovery from a sign-in in progress (mfa_transaction) and a recovery code. The code is spent and the + sign-in ends; the recovery secret is returned once. A wrong code counts against the sign-in and the account lockout. + + + + What the recovery allows next, and the passkeys the user may choose to remove. No factor secret is ever returned. + + + Stages the replacement authenticator; the current one keeps working until the recovery completes. + + + + Completes the recovery with a code from the new authenticator: it becomes the authenticator, the recovery codes are + replaced (returned once), the chosen passkeys are removed, and every session and piece of evidence ends. A wrong + code counts against the recovery; nothing changes until the code verifies. + + + + Ends the recovery; the recovery code that opened it stays spent, and nothing about the account changes. + Built-in feature toggle API. The poll endpoints are available to any authenticated user and are @@ -2848,6 +3141,77 @@ MessageId of the message to delete Returns OK status code if successful + + + Responder approval (passkey plan section 7.9; workbook section 7.4). The requesting app asks with its login + transaction (sign-in) or its signed-in session (step-up) and shows the returned number; the user's own Responder + reviews the request, types that number and approves with its passkey; the requester then completes through + Authentication/CompleteApproval or Mfa/VerifyStepUp. Nothing here issues a token, and nothing is issued + to Responder. + + + + Asks the user's Responder to approve; returns the number to show on this screen only. + + + The request's state, for the requester that made it. Poll every 2 seconds. + + + The request waiting for this user's approval, for their own signed-in Responder; empty when none. + + + Assertion options for this Responder's approval passkeys, bound to the request. + + + Approves with the number from the requesting screen and a Responder passkey assertion. + + + Denies the request; not_me also ends that sign-in and suspends approval requests for 15 minutes. + + + + Stops approval requests on one Responder installation (InstallationId from AccountSecurity/Methods), or + on all of them with All, which also turns approval off on every Responder passkey. Works from any app; needs + the authenticator or a passkey for this app within five minutes, never an approval (plan section 7.6 row 14). + + + + The approver: the validated session asking, if it is one (the service checks it is a personal Responder session). + + + The requester: the login transaction whose secret was sent, otherwise the signed-in session. + + + + API step-up for sensitive operations (passkey plan sections 7.6 and 11): the v4 replacement for per-feature + verification. A verified second factor becomes server-side evidence on the caller's validated session, with its + method and time; it returns no token and authorizes nothing by itself. The guarded commands check the evidence. + + + + The methods the caller can use for , and which to show first. + + + + Verifies a second factor for operation and records it as evidence on this session. A TOTP failure counts + against the account lockout shared with sign-in (plan section 7.5 rule 6); a passkey is limited by its single-use + request instead, since a signature cannot be guessed. + + + + + Uses a Responder approval requested by this session for this operation (plan section 7.9 step 6). The department + must accept approval here (never security changes or account factors), and the approving passkey and Responder + session must still count; the evidence names them and carries the approval time. + + + + + Redeems a provider step-up begun through Sso/Begin (purpose step_up) for this session and operation. + Evidence carries the provider's own authentication time and names the SSO configuration and mapping version, so a + mapping change retires it (plan section 7.8). + + Department and group-scoped moderation requests across supported content types. @@ -2943,6 +3307,38 @@ List of string of distinct note category. + + + Passkey enrollment and the user's own inventory (passkey plan sections 6.1, 6.5 and 11). A passkey is registered for + the calling app only and works only there; the inventory covers every app, so a passkey made in one app can be + removed from any other. Every command rechecks the rollout gate and the session's server-side evidence. + + + + + Creation options for a new passkey bound to this app. Needs a password or SSO verification and a second factor + for this session within five minutes, and an authenticator app with recovery codes already set up. + + + + Verifies the platform's response and stores the passkey. The key is not usable until this returns. + + + The caller's own active passkeys in every app. Never another user's. + + + + Removes one passkey at Resgrid, in any app, after a second factor within five minutes (authenticator app, or a + passkey for this app). It may remain in the device or password manager but can no longer be used. Sessions that + signed in with it end, possibly including this one (CurrentSessionEnded). + + + + Removes every passkey the caller has for one app, under the same verification rule as . + + + Responder passkeys only: allow or stop approving other apps' requests. + Operations to perform against personnel in a department @@ -3893,7 +4289,75 @@ - User authentication session inventory and revocation. + + User authentication session inventory and revocation, and the shared vehicle and workstation session lifecycle (passkey + plan sections 11 and 12.5): status, lock, unlock and end shift. Those are the only endpoints a locked shared session can + reach; session validation refuses it everywhere else. + + + + This session's operator, lock state and deadlines. Available while locked. + + + + Locks this shared session now (Lock, an OS lock, or the app going to the background). Everything issued before it, + including Protected Data Grants and pending verifications, stops working. Locking a locked session is fine. + + + + + End shift or Switch operator: ends this shared session. The client then clears the operator's tokens, caches and + connections and signs the next operator in normally. Available while locked. + + + + + How the locked session's own operator can unlock it, bound to its current lock version. Empty methods mean quick + unlock is unavailable (no factor, or the department's rules exclude them): end the shift and sign in normally. + + + + + Asks the operator's own Responder to approve this unlock (plan section 7.9). The request is bound to this lock + version; show the number on this screen only, then poll unlock-approval/{id}. + + + + + Begins an unlock through the department's identity provider, which must perform MFA the department's tested mapping + accepts (plan section 7.8). The provider is asked to let the operator choose the account. The transaction is bound + to this session and counts only for the lock it began in; finish with complete-unlock method federated. + + + + The unlock approval's state, for this session only. Poll every 2 seconds. + + + Cancels this session's unlock approval request. + + + + Unlocks this locked shared session for its own operator with a TOTP code, a passkey for this app, or an approved + Responder request, all at the lock version from unlock-options. The same session resumes: the first-factor + time and the shift end do not change, and nothing from before the lock works again. + + + + + Redeems a provider step-up begun with unlock-sso for this session and lock: the same department and tested + mapping, this session and operator, the current generation, and begun after the session's last lock, so a round + trip from before a lock or for anything else never unlocks it. Evidence names the SSO configuration and mapping + version, so a mapping change retires it. + + + + + The locked session this request is about, its operator, and whether they may try to unlock it now: shared, locked, + still at the lock version the client saw, and not waiting on an SSO sign-in. + + + + The session request validation accepted for this caller; for a locked session, only on these endpoints. @@ -4033,9 +4497,16 @@ on input and encrypted before storage — they are NEVER returned in any response. - + Constructor. + + + Security, SSO, SCIM and MFA policy changes need an actual second factor on this session within the last few + minutes (passkey plan section 7.6 row 13): call Mfa/VerifyStepUp with operation security_change, then + retry. Null when the change may proceed. + + Returns all SSO configurations for the current department. @@ -4091,6 +4562,24 @@ a password — ensure at least one working SSO configuration exists first. + + The active SSO configuration's provider step-up mapping and whether it has passed its test. + + + + Saves (or removes, with a null mapping) the provider step-up mapping. Managing member only, after an authenticator + app or passkey step-up; every change advances the version and needs a new test before it counts. + + + + Starts the managing member's test step-up with the saved mapping; it proves the provider returns a mapped MFA value. + + + + Completes the test: the provider's fresh response carried a value the mapping counts as MFA, so that exact mapping + version becomes effective. A mapping changed during the test must be tested again. + + Validates that the SCIM endpoint is reachable and the stored SCIM bearer token @@ -4098,6 +4587,39 @@ the local SCIM controller. Returns success/failure and the HTTP status received. + + + Server-brokered SSO for every client (passkey plan section 7.7.2; workbook section 7.3). Begin returns the + IdP URL; the IdP returns to the server, which sends a one-time code to the client's registered return target; + Redeem exchanges that code and the PKCE verifier for the login MFA transaction (or reauthentication). No IdP + token or assertion ever reaches the client, and SAML + TOTP works because nothing is resent. + + + + + Redeems the one-time code with the PKCE verifier. A login continues exactly like a password sign-in: MFA through + the login transaction when the account has it, a direct completion code when it needs none (plan section 7.6 row + 4), and mfa_enrollment_required when the department requires MFA the account lacks. + + + + + Fresh SSO proof for the session that asked for it (plan section 6.2): the same session, account and generation, + recorded as first-factor evidence at the IdP's own authentication time. It signs nobody in. + + + + + The tested SSO configuration when the sign-in's round trip carried provider MFA the department accepts for login + under its current mapping; null otherwise, and the sign-in continues as any SSO first factor. + + + + + Whether a sign-in is from a shared installation: it says so, or the department makes this app's sessions shared. + Either way the provider is asked to let the operator choose the account (plan section 12.5.2). + + The options for Personnel Statuses, Staffing and Unit Statuses that can be used to submit their status to Resgrid. @@ -6939,6 +7461,48 @@ part of the shallow /health liveness endpoint. + + + The client application a sign-in request says it is (X-Resgrid-Client). A label for session metadata and for + binding a login transaction to the app that started it; never proof of the app's identity. + + + + + Shared v4 plumbing for passkey ceremonies: the caller comes from the session that request validation accepted, and + each service outcome maps to one status and one code from the shared vocabulary (workbook section 7.6). + + + + + The WebAuthn credential JSON as the client sent it: a JSON object (PublicKeyCredential.toJSON()) or a string + holding one. Null when absent. + + + + Embeds the server's options JSON unchanged, as an object rather than an escaped string. + + + The API name of a client, as the relying-party configuration uses it. + + + The clients a passkey can be bound to, in display order (plan section 6.5). + + + + Server-side MFA evidence for the API caller's validated session (passkey plan section 7.6). The session and the + generation come from session validation, never from token claims; without a tracked session there is no evidence. + A Web session's API bridge carries the Web session's id, so evidence recorded on Web counts here too. + + + + + True when this session completed an actual second factor within , with a method the + department accepts for now (passkey plan section 10.1). With + , provider step-up does not count: it cannot authorize turning itself on or + changing its own mapping (plan section 7.8). + + Request-bound (ADP plan 3.3 / 7.2): the caller's Protected Data @@ -6947,6 +7511,41 @@ replaces the workload default the module ships for hosts without requests. + + + The session the request's session validation accepted (passkey plan section 8.3); null for a workload, an + untracked session, or a request that did not pass session validation. + + + + The validated session of an HTTP request, for controllers that check a grant themselves. + + + + Where the legacy SAML relay (connect/saml-mobile-callback) sends the app back. Every department registers the + same ACS URL with its IdP, so the only per-sign-in value that comes back is the RelayState the app sent. An app tags + it with its own name (unit.<nonce>), and the relay returns to that app's own auth/callback, + echoing the RelayState as relay_state so the app can check that the sign-in is the one it started (login CSRF). + The return targets are the fixed ones in , one custom scheme per app; a RelayState + naming a target is never trusted. An untagged or unrecognized RelayState keeps the original behavior: Responder's + scheme, with nothing echoed. + + + + The app an untagged RelayState returns to, as the relay always did before apps tagged it. + + + The app to return to, its callback, and the RelayState to echo (null when there is nothing to echo). + + + The app to return to, its callback, and the RelayState to echo (null when there is nothing to echo). + + + + The deep link for the app: the single-use relay token (never the assertion), the encrypted department token the app + sends back to external-token, and the echoed RelayState when the app sent one. + + TEMPORARY (RG-T132): serialises a UTC instant WITHOUT the trailing "Z". @@ -7075,10 +7674,40 @@ the department. Callers treat null-with-a-system-principal as a denial, not as "unrestricted". - + - The one rule for a unit's coordinates leaving the v4 API, the same one the map applies: they go out only when - the caller passes See Unit Locations for that unit (the unit-location visibility matrix). A unit the caller may + The OpenIddict token endpoints and grants the API serves, shared by Startup and the HTTP tests so both run the same + configuration. + + + + The legacy SSO exchange (plan section 7.7.4), which answers with a token response like the token endpoint. + + + + The grant every request to carries. App builds post there without a + grant_type, so the server supplies it; the route only ever performs the SSO exchange. + + + + + The narrow set of API endpoints a locked shared session may still reach (passkey plan section 12.5.3): its own status, + lock, unlock and end shift. Everything else refuses it, including refresh, grants and SignalR. + + + + HttpContext.Items key under which session validation leaves the validated (or locked) session row. + + + The session row this request was validated against (locked only on the endpoints above); null otherwise. + + + True when the client marked this request as caused by the operator (X-Resgrid-Operator-Activity: 1). + + + + The one rule for a unit's coordinates leaving the v4 API, the same one the map applies: they go out only when + the caller passes See Unit Locations for that unit (the unit-location visibility matrix). A unit the caller may see but not locate is still returned, with its coordinates withheld (null). An endpoint that returns nothing but a location refuses the request instead. @@ -7137,9 +7766,25 @@ The context. Task. + + + Closes this host's SignalR connections whose session ended, locked or passed its idle deadline, every + SessionSecurityConfig.ConnectionSweepIntervalSeconds (passkey workbook section 12, slice 16). Invocations are + validated as they arrive; this stops a connection that only listens from receiving broadcasts after its session ends. + + Revalidates user session state for every invocation on an open API SignalR connection. + + + A connection with a user session joins that session's group, for events meant for it alone, and is tracked so the + sweep can close it once the session ends or locks (slice 16). + + + + The tracked user session a connection belongs to; null for workloads and pre-session tokens. + Enforces account-wide credential cutoffs and per-session revocation on every @@ -7222,6 +7867,173 @@ cropped image height + + + The signed-in user's own sign-in methods (plan section 6.5): the same view in every app. + + + + The app this request came from: web, responder, unit, dispatch or ic. + + + One group per app a passkey can be bound to, in display order. + + + Responder installations that can receive approval requests; stop them through MfaApproval/Installations/Disable. + + + Department identity-provider links. Informational: the department and SCIM manage them. + + + Verifications in the last TwoFactorConfig.MfaActivityRetentionDays days, newest first (at most 100). + + + Pass to MfaApproval/Installations/Disable. + + + This request came from this installation. + + + Requests reach it now: approvals are not stopped here and a Responder passkey can approve. + + + approved or denied; null when it has decided nothing recently. + + + saml2 or oidc. + + + The department accepts provider step-up and its mapping is tested, so this identity can verify there. + + + Pass to AccountSecurity/ReportActivity. + + + totp, passkey, passkey_approval, federated or recovery_code. + + + login, reauthentication, step_up, adp_step_up or shared_unlock. + + + False for a denied verification: a wrong code, a failed passkey or a denied approval. + + + It was for the session making this request; reporting it does not end this session. + + + The session the verification opened or served was ended. + + + What the app should offer next: change_password, review_methods. + + + Resgrid cannot see where copies of an authenticator's setup key exist; replacing it is the remedy. + + + The current authenticator was set up on a shared installation, so its setup key may have been seen there. + + + The app and installation the current authenticator was set up or last replaced from, when recorded. + + + Where its last successful use in the recent-activity window came from. + + + False while any passkey exists: turning TOTP off is blocked in this release (plan section 7.5 rule 7). + + + Whether a passkey for this app can be registered on this deployment now (from that app). + + + A new authenticator key, staged; add it to an authenticator app, then send a code from it to ReplaceTotp. + + + The key to type into an authenticator app, in groups of four. + + + The otpauth:// URI to show as a QR code. + + + A code from the new authenticator. + + + The new recovery codes, shown once; every earlier code no longer works. + + + Always true: every session, this one too, has ended. + + + Starts "I lost my authenticator": the sign-in in progress (mfa_transaction) and one recovery code. + + + The recovery secret from BeginFactorRecovery; keep it in memory only. + + + A code from the new authenticator staged by PrepareReplacement. + + + Lost passkeys to remove, from the recovery status; others are kept. + + + The recovery secret, returned once by BeginFactorRecovery; null otherwise. + + + pending or canceled. + + + prepare_replacement, complete and cancel. + + + The account's passkeys, to choose any that were lost. + + + + The login MFA transaction a password grant returned as mfa_transaction (workbook section 7.1). The secret is + the only authority these endpoints accept; keep it in memory and send it only in request bodies. + + + + A TOTP code or a recovery code for the transaction. + + + The request id from Authentication/PasskeyOptions. + + + The platform's assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + + + A new authenticator key staged for the setup transaction; add it to an authenticator app, then send a code. + + + The key to type into an authenticator app, in groups of four. + + + The otpauth:// URI to show as a QR code. + + + A Responder approval requested for the transaction (MfaApproval/Request with purpose login) and approved. + + + Provider step-up for the transaction: the round trip from Sso/Begin (purpose step_up, same transaction). + + + The one-time sso_code the return target received. + + + The PKCE verifier whose S256 challenge began the step-up. + + + + The one-use completion code. Redeem it at /api/v4/connect/token with + grant_type=urn:resgrid:params:oauth:grant-type:mfa_completion, completion_code and transaction. + + + + True when a recovery code completed the sign-in: the session is recovery-classified; replace the lost factor. + + + After CompleteTotpSetup: the new recovery codes, shown once. Null otherwise. + Autofills in the Resgrid system @@ -11633,6 +12445,21 @@ Step-up verification payload: the user's current authenticator (TOTP) code. Never logged. + + A passkey assertion for DataProtection/VerifyPasskey (passkey plan section 8.1). + + + The request id from DataProtection/PasskeyOptions. + + + The platform's assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + + + An approved MfaApproval/Request (purpose adp) to use once for a grant. + + + A brokered provider step-up (Sso/Begin, purpose adp_step_up) to redeem once for a grant. + Enrollment Wizard final-confirmation payload (ADP plan section 18.1 step 8). Everything here is @@ -11728,25 +12555,36 @@ Result of a successful step-up verification. The window is ABSOLUTE (never sliding): clients conceal protected values at StepUpExpiresOnUtc and prompt again on the next reveal/edit. - When grant signing is configured on this deployment, GrantId/GrantToken carry a signed - Protected Data Grant the client presents alongside its access token on protected operations; - clients hold the token in MEMORY ONLY (never persisted) and discard it at expiry. On - deployments without signing key material both stay null and the verification itself remains - the capability (pre-broker behavior). + GrantId/GrantToken carry a signed Protected Data Grant the client presents alongside its access + token on protected operations; clients hold the token in MEMORY ONLY (never persisted) and discard + it at expiry. A deployment without signing key material answers 503 grants_not_configured + instead: a verification without a grant is not access (passkey plan section 8.1). - Unique grant id (jti) for display/audit correlation; null when grants are not configured. + Unique grant id (jti) for display/audit correlation. - Signed Protected Data Grant token; null when grants are not configured. MEMORY ONLY. + Signed Protected Data Grant token. MEMORY ONLY. - Absolute UTC expiry of this step-up window (ISO 8601). + + The grant's absolute UTC expiry (ISO 8601): the verification time plus the department's window, never past the + session's end (passkey plan section 9.2). Conceal at this time; never add the window to the client's own clock. + The department's effective step-up window in minutes. + + + How the caller can verify for this department's protected data now (passkey plan section 7.5 rule 5): every usable + method is an equal choice, and is the one to show first. Advisory: every command rechecks. + + + + Methods the caller has and the department accepts for protected data: totp, passkey, passkey_approval, federated. + Result of a department lookup by dispatch email code. @@ -13062,6 +13900,126 @@ Identifier of the new message + + + The second-factor choice for a named operation (passkey workbook section 7.2). Advisory: VerifyStepUp + re-checks everything. + + + + Methods the user can verify with now: enrolled and allowed. + + + The method to show first; null when nothing is usable. + + + True when the user must enroll a second factor before this operation. + + + How long one verification serves this operation. + + + + Assertion options for this app's passkeys, when a passkey is usable for the operation; send its request id and + the platform's response to VerifyStepUp with method passkey. + + + + One of security_change, adp_management, chat_export or account_security. + + + totp (the default), passkey, passkey_approval (Responder approval) or federated (provider step-up). + + + The current authenticator code, for totp. + + + The request id from StepUpOptions, for passkey. + + + The platform's assertion (PublicKeyCredential.toJSON()), for passkey. + + + For passkey_approval: the approved request from MfaApproval/Request (purpose step_up, same operation). + + + For federated: the transaction from Sso/Begin (purpose step_up, same operation). + + + For federated: the one-time sso_code the return target received. + + + For federated: the PKCE verifier whose challenge began the step-up. + + + Evidence stays on the server; no token is returned (workbook section 7.2). + + + When the second factor was verified (ISO 8601 UTC). + + + When this verification stops serving the operation (ISO 8601 UTC). + + + + Asks the user's Responder to approve (workbook section 7.4). Send only after the user chose "Approve with Responder"; + never automatically. + + + + login (with ) or step_up (the signed-in session, with ). + + + For step_up: adp_management or chat_export. Security changes and account factors never accept approval. + + + For login: the login MFA transaction secret. + + + Show this on the requesting screen only; the user types it in Responder. It is never pushed. + + + A request the caller made: by its login transaction secret, or by the signed-in session when absent. + + + pending, approved, denied, expired, canceled or consumed. Poll every 2 seconds. + + + Null when nothing is waiting. + + + What Responder shows before Approve and Deny. The number is not here: the user reads it from the requesting screen. + + + web, unit, dispatch or ic. + + + login or step_up. + + + Region and country only. + + + The two digits the user read from the requesting screen. + + + The request id from MfaApproval/Options. + + + The Responder passkey assertion (PublicKeyCredential.toJSON()), as an object or a JSON string. + + + The installation to stop, from AccountSecurity/Methods. Leave empty with . + + + Stop every installation and turn approval off on every Responder passkey. + + + Zero when nothing was taking requests (already stopped, or not this account's installation). + + + declined, or not_me ("I didn't request this": ends that sign-in and suspends approval requests). + Result containing all the data required to populate the Notes form @@ -13192,6 +14150,176 @@ Identifier of the new npte + + + The result of getting all personnel filters for the system + + + + + The Id value of the filter + + + + + The type of the filter + + + + + The filters name + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Result that contains all the options available to filter personnel against compatible Resgrid APIs + + + + + Response Data + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Information about a User + + + + + The UserId GUID/UUID for the user + + + + + DepartmentId of the deparment the user belongs to + + + + + Department specificed ID number for this user + + + + + The Users First Name + + + + + The Users Last Name + + + + + The Users Email Address + + + + + The Users Mobile Telephone Number + + + + + GroupId the user is assigned to (0 for no group) + + + + + Name of the group the user is assigned to + + + + + Enumeration/List of roles the user currently holds + + + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + A GPS location for a point in time of a specificed person @@ -14705,6 +15833,91 @@ Is the user a group admin + + The caller's own session state (passkey plan section 12.5). A locked shared session may read it. + + + The app the session belongs to: web, responder, unit, dispatch or command. + + + Send it back to unlock; it changes with every lock. + + + explicit or idle while locked. + + + When the session locks unless the operator does something (ISO 8601 UTC); null while locked. + + + When the shift ends whatever happens (ISO 8601 UTC). Warn before it. + + + True for Switch operator: the same as ending the shift, audited as a handoff. + + + Always true: discard this session's tokens and caches, then sign the next operator in normally. + + + How the locked session's operator can unlock it (plan section 12.5.3). Nothing verifies here. + + + The lock version the client last saw; a newer lock means a newer screen. + + + + What this operator can unlock with here: totp, passkey, passkey_approval and federated + (the identity provider's MFA, begun with unlock-sso). Empty means quick unlock is unavailable; end the shift + and sign in normally. + + + + The method to offer first. Shared installations never start the passkey prompt on their own. + + + Assertion options bound to this lock, when passkey is offered. + + + The lock version from unlock-options. A lock since then refuses the unlock. + + + totp, passkey, passkey_approval or federated. + + + For totp: the current authenticator code. + + + For passkey: the request id from unlock-options. + + + For passkey: the platform's assertion (PublicKeyCredential.toJSON()). + + + For passkey_approval: the approved request from unlock-approval. + + + For federated: the transaction from unlock-sso. + + + For federated: the one-time sso_code the return target received. + + + For federated: the PKCE verifier whose challenge began the unlock. + + + Begins an unlock through the department's identity provider with MFA (provider step-up, plan section 7.8). + + + ios, android, web or desktop. + + + This app's registered return target. + + + Opaque client state returned with the code. + + + S256 PKCE challenge; the verifier goes to complete-unlock. + A single Shift day result @@ -15078,6 +16291,95 @@ Why the signup failed (snake_case code), empty on success + + + Starts a server-brokered SSO sign-in (workbook section 7.3). Name the department with one of the three + identifiers. The client keeps its PKCE verifier in memory and sends only the S256 challenge. + + + + web, responder, unit, dispatch or ic; defaults to the X-Resgrid-Client header. + + + ios, android, web or electron; a label only. + + + Where the one-time code is sent: a return target registered for this client, matched exactly. + + + The client's CSRF value, echoed back unchanged on return. + + + Only S256. + + + + login (default), reauth (fresh proof for the signed-in session), or step_up (provider MFA: + for the signed-in session's , or to complete the password sign-in in ). + + + + For a session step_up: the operation it serves (security_change, adp_management, chat_export). + + + + For a step_up that completes a password sign-in: the login MFA transaction secret. Redeem the step-up's code + at Authentication/CompleteFederated. + + + + Open this in the platform's authentication session, the system browser, or a top-level redirect. + + + The one-time sso_code from the return target. + + + The PKCE verifier whose S256 challenge began the sign-in. + + + Optional OAuth client_id, as the password grant takes it (refresh-token lifetime). + + + + mfa_required: complete the second factor through Authentication/* with . + completed: no second factor is required; redeem and + with the mfa_completion grant. reauthenticated: the session has fresh SSO proof. + + + + The login MFA transaction secret (for mfa_required and completed). + + + For completed: federated when the provider's MFA satisfied the sign-in; null when none was needed. + + + + The department's provider step-up mapping (passkey plan section 7.8) on its active SSO configuration, and whether it + is effective: a mapping counts only after a successful test at its current version. + + + + oidc or saml2: which mapping fields apply. + + + + The mapping: requestAcrValues, requestClaims (OIDC), requestAuthnContextClassRefs (SAML), and + what counts as MFA: acceptAmr, acceptAcr, acceptAcrs (OIDC) or acceptAuthnContextClassRefs + (SAML). Null when none is set. + + + + True when the mapping passed its test at the current version and so can be used. + + + The mapping object (see ), or null to remove it. + + + Starts the managing member's test step-up with the saved mapping, through brokered SSO. + + + The returned value the mapping counted as MFA, as kind:value. + Response for the SSO configuration discovery endpoint consumed by mobile apps. @@ -15113,6 +16415,14 @@ SAML entity ID / service-provider identifier (SAML only). + + + Where an app opens a legacy (unbrokered) SAML sign-in, with RelayState=<app>.<nonce> added: this + server's start page, which sends the browser to the department's IdP with an AuthnRequest. The response returns to + the app through connect/saml-mobile-callback. SAML only, and only when the department's configuration can + start one (its IdP SSO URL, entity ID, ACS URL and IdP certificate). + + Whether local username/password login is permitted in addition to SSO. @@ -15124,8 +16434,9 @@ - The redirect URI the mobile app must use for the OIDC authorization-code flow. - Format: resgrid://auth/callback + The redirect URI the calling app uses for the legacy (unbrokered) OIDC authorization-code flow: its own scheme, by + the app named in X-Resgrid-Client (for example resgridunit://auth/callback). A caller that names no + app gets Responder's resgrid://auth/callback. @@ -15134,6 +16445,18 @@ e.g. "openid email profile offline_access" + + The department, for clients that must name it again (for example Dispatch's department selection). + + + + Opaque, system-encrypted department token: send it as department_token (legacy external-token) or + DepartmentToken (Sso/Begin) instead of a department code or username. + + + + True when this department can sign in through server-brokered SSO (Sso/Begin). + Response wrapper for a list of SSO configurations. @@ -15230,6 +16553,22 @@ SAML Assertion Consumer Service URL. + + The IdP's SAML single sign-on URL that brokered sign-in sends its AuthnRequest to. + + + + The OIDC redirect URI to register with the IdP for brokered sign-in (plan section 7.7.2 item 7). It is added + alongside any existing registration, which older app builds keep using. + + + + + Each app's own redirect URIs for sign-in the app runs itself (older app versions, or while brokered sign-in is off): + its native scheme, and its web edition's page where this deployment serves one. The department registers every one + with its IdP; an app can only receive a redirect on an address it owns. OIDC only. + + JSON attribute mapping from IdP claim names to Resgrid user fields. @@ -15251,6 +16590,15 @@ Whether a SCIM bearer token is currently stored. + + One of an app's legacy OIDC redirect URIs. + + + The app, as X-Resgrid-Client names it: responder, unit, dispatch or ic. + + + True for the app's web edition (a page on its web host), false for the native app's own scheme. + Department security policy data. @@ -15284,6 +16632,39 @@ Data classification level: 0=Unclassified, 1=CUI, 2=Confidential. + + Accept a passkey as sign-in and step-up MFA. + + + Accept a passkey for protected data. + + + Accept provider step-up for sign-in and step-up MFA. + + + Accept provider step-up for protected data. + + + Accept Responder approval. + + + Let recent sign-in MFA serve protected data. + + + Let fresh unlock MFA serve protected data. + + + Minutes of no operator activity before a shared session locks. + + + Hours after sign-in when a shared session ends. + + + Apps whose sessions are always shared (flags: Unit 1, IC 2, Dispatch 4). + + + Advanced by the server whenever the sign-in MFA rules change; read-only. + Date/time the policy was created. @@ -15320,6 +16701,9 @@ SAML Assertion Consumer Service URL. + + The IdP's SAML single sign-on URL (HTTP-Redirect binding) for brokered sign-in. Optional; https. + IdP public certificate in PEM format (plaintext). Encrypted before storage. @@ -15377,6 +16761,39 @@ Data classification level: 0=Unclassified, 1=CUI, 2=Confidential. + + Accept a passkey bound to the requesting app as sign-in and step-up MFA. TOTP is always accepted. + + + Accept a passkey for Protected Data Grants, ADP management and protected workflows. + + + Accept provider step-up for sign-in and step-up MFA (needs a tested mapping). + + + Accept provider step-up for protected data (needs a tested mapping). + + + Accept Responder approval where the matching passkey switch is on. + + + Let recent same-session sign-in MFA serve protected data without another verification. + + + Let fresh same-operator unlock MFA serve protected data on a shared session. + + + Shared sessions lock after this many idle minutes (1-15). Managing member only; omitted leaves it unchanged. + + + Shared sessions end this many hours after sign-in (1-24). Managing member only. + + + + The apps whose sessions are always shared: flags Unit 1, IC 2, Dispatch 4. Sign-ins that do not name their app count + too. Managing member only; a new app needs the deployment's shared-device mode. + + The standard response base object for the v4 api. A Data property will be adding on top of this. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 0d3b27ca8..3084f3059 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -162,7 +162,10 @@ public void ConfigureServices(IServiceCollection services) config.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); config.Lockout.MaxFailedAccessAttempts = 5; config.Lockout.AllowedForNewUsers = true; - }).AddDefaultTokenProviders().AddClaimsPrincipalFactory>(); + }).AddDefaultTokenProviders() + // One-time TOTP steps (passkey plan section 7.5 rule 8): replaces Identity's authenticator provider. + .AddTokenProvider(TokenOptions.DefaultAuthenticatorProvider) + .AddClaimsPrincipalFactory>(); services.AddCors(); @@ -555,30 +558,13 @@ public void ConfigureServices(IServiceCollection services) // Register the OpenIddict server components. .AddServer(options => { - options.RegisterScopes( - Scopes.Profile, - Scopes.Email, - Scopes.OfflineAccess, - "mobile", - "web"); - - // Enable the token endpoint. - options.SetTokenEndpointUris("/api/v4/connect/token"); + // The scopes, token endpoints and grants (connect/token and the legacy connect/external-token exchange). + Resgrid.Web.Services.Helpers.ResgridTokenEndpoints.UseResgridTokenEndpoints(options); options.SetIntrospectionEndpointUris("/api/v4/connect/introspect"); options.SetAccessTokenLifetime(TimeSpan.FromMinutes(OidcConfig.AccessTokenExpiryMinutes)); options.SetRefreshTokenLifetime(TimeSpan.FromDays(OidcConfig.RefreshTokenExpiryDays)); - // Enable the password and the refresh token flows. - //options.AllowPasswordFlow() - // .AllowRefreshTokenFlow(); - options//.AllowAuthorizationCodeFlow() - //.AllowHybridFlow() - .AllowClientCredentialsFlow() - .AllowPasswordFlow() - .AllowRefreshTokenFlow() - .AllowCustomFlow("web_session"); - // Accept anonymous clients (i.e clients that don't send a client_id). options.AcceptAnonymousClients(); @@ -653,6 +639,9 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddHostedService(); + // Open SignalR connections by session, closed by the sweep once their session ends or locks (slice 16). + services.AddSingleton(); + services.AddHostedService(); services.AddScoped(); this.Services = services; @@ -746,6 +735,8 @@ public void ConfigureContainer(ContainerBuilder builder) // ADP broker CLIENT only (no key material, no KMS route) — the app tier asks the broker // to act on a caller's grant. The real KMS adapter module is broker-host-only. builder.RegisterModule(new Resgrid.Providers.ProtectedData.ProtectedDataBrokerClientModule()); + // WebAuthn ceremonies for passkey enrollment and step-up; every passkey gate starts off (PasskeyConfig). + builder.RegisterModule(new Resgrid.Providers.Authentication.AuthenticationProviderModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType>().As>().InstancePerLifetimeScope(); @@ -765,6 +756,10 @@ public void ConfigureContainer(ContainerBuilder builder) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { + // Passkeys fail closed on a bad relying-party configuration; say so at startup (plan section 10.3). + Resgrid.Services.PasskeyReadinessReporter.Report(app.ApplicationServices.GetService(typeof(Resgrid.Model.Services.IRelyingPartyRegistry)) as Resgrid.Model.Services.IRelyingPartyRegistry); + Resgrid.Repositories.DataRepository.Stores.AuthenticatorSeedProtector.ReportReadiness(); + app.UseForwardedHeaders(); app.UseMiddleware(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs index 334954a3f..fc86a3c23 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/AccountSecurityController.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -21,6 +22,9 @@ namespace Resgrid.Web.Areas.User.Controllers // an empty UserId and blows up in the Identity store instead of being sent to the sign-in page. [Area("User")] [Authorize] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): signing in + // and out, locking and unlocking a shared session, and verifying a second factor touch no department data. + [Resgrid.Web.Filters.AllowDuringDepartmentLock] public class AccountSecurityController : SecureBaseController { private readonly IUserSessionService _userSessionService; @@ -29,17 +33,103 @@ public class AccountSecurityController : SecureBaseController private readonly IDepartmentSsoService _departmentSsoService; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly IDepartmentsService _departmentsService; + private readonly SignInManager _signInManager; + private readonly IMfaEvidenceService _mfaEvidenceService; + private readonly ISsoBrokerService _ssoBroker; + private readonly ISsoReturnTargetRegistry _ssoReturnTargets; public AccountSecurityController(IUserSessionService userSessionService, ISystemAuditsService systemAuditsService, UserManager userManager, IDepartmentSsoService departmentSsoService, - IExternalIdentityLinkService externalIdentityLinkService, IDepartmentsService departmentsService) + IExternalIdentityLinkService externalIdentityLinkService, IDepartmentsService departmentsService, + SignInManager signInManager, IMfaEvidenceService mfaEvidenceService, ISsoBrokerService ssoBroker, + ISsoReturnTargetRegistry ssoReturnTargets) { + _ssoBroker = ssoBroker; + _ssoReturnTargets = ssoReturnTargets; _userSessionService = userSessionService; _systemAuditsService = systemAuditsService; _userManager = userManager; _departmentSsoService = departmentSsoService; _externalIdentityLinkService = externalIdentityLinkService; _departmentsService = departmentsService; + _signInManager = signInManager; + _mfaEvidenceService = mfaEvidenceService; + } + + // ── Reauthenticate (passkey plan section 6.2) ───────────────────────────────── + + /// Whether this department's provider can confirm the account instead of a password (plan section 7.7.2 item 9). + private async Task SsoReauthenticationAvailableAsync(CancellationToken cancellationToken) + { + if (!WebSsoRoundTrip.IsAvailable(_ssoBroker, _ssoReturnTargets) || HttpProtectedGrantContext.SessionOf(HttpContext) == null) + return false; + + var configs = await _departmentSsoService.GetSsoConfigsForDepartmentAsync(DepartmentId, cancellationToken); + return configs?.Any(c => c.IsEnabled && _ssoBroker.SupportsBrokered(c)) == true; + } + // Confirms the password of the account ALREADY signed in to this session and records fresh first-factor evidence + // for it. It cannot switch accounts, does not extend the session, and a refresh or remembered browser never counts. + + [HttpGet] + public async Task Reauthenticate(string returnUrl, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return NotFound(); + + return View(new ReauthenticateView + { + ReturnUrl = SafeReturnUrl(returnUrl), + PasswordNotAllowed = !await IsPasswordReauthenticationAllowedAsync(user, cancellationToken), + SsoAvailable = await SsoReauthenticationAvailableAsync(cancellationToken) + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Reauthenticate(ReauthenticateView model, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return NotFound(); + + model.ReturnUrl = SafeReturnUrl(model.ReturnUrl); + model.PasswordNotAllowed = !await IsPasswordReauthenticationAllowedAsync(user, cancellationToken); + model.SsoAvailable = await SsoReauthenticationAvailableAsync(cancellationToken); + if (model.PasswordNotAllowed) + { + ModelState.AddModelError(string.Empty, "This account signs in through your organization. Sign out and sign in again with single sign-on to continue."); + return View(model); + } + + if (!ModelState.IsValid) + return View(model); + + // Same lockout accounting as sign-in, so this is not an unthrottled password oracle. + var result = await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true); + await AuditReauthenticationAsync(result.Succeeded, result.IsLockedOut ? "locked-out" : null, cancellationToken); + + if (result.IsLockedOut) + { + ModelState.AddModelError(string.Empty, "Too many incorrect attempts. Your account is temporarily locked; try again later."); + return View(model); + } + + if (!result.Succeeded) + { + ModelState.AddModelError(nameof(model.Password), "That password is not correct."); + return View(model); + } + + var sessionKey = MfaEvidenceSession.KeyFor(User, HttpContext); + if (sessionKey != null) + { + await _mfaEvidenceService.RecordAsync(user.Id, sessionKey, UserSessionClientApplication.Web, MfaEvidenceKind.FirstFactor, + MfaEvidenceMethod.Password, MfaEvidencePurpose.Reauthentication, DateTime.UtcNow, user.AuthenticationGeneration, + cancellationToken: cancellationToken); + } + + return Redirect(model.ReturnUrl); } [HttpGet] @@ -228,6 +318,44 @@ private static string SessionSupportSuffix(string sessionId) => ? sessionId : sessionId.Substring(sessionId.Length - 8); + // Mirrors AccountController.IsPasswordLoginAllowedAsync: if a password could not sign this account in, it cannot + // reauthenticate it either. + private async Task IsPasswordReauthenticationAllowedAsync(IdentityUser user, CancellationToken cancellationToken) + { + if (!await _externalIdentityLinkService.IsLocalLoginAllowedAsync(user.Id, cancellationToken)) + return false; + + var department = await _departmentsService.GetDepartmentForUserAsync(user.UserName); + if (department == null) + return true; + + if (!await _externalIdentityLinkService.IsLocalLoginAllowedAsync(user.Id, department.DepartmentId, cancellationToken)) + return false; + + var requiresSso = await _departmentSsoService.IsRequireSsoPolicyActiveAsync(department.DepartmentId, cancellationToken); + return !requiresSso || !await _departmentSsoService.IsSsoEnabledForDepartmentAsync(department.DepartmentId, cancellationToken); + } + + private string SafeReturnUrl(string returnUrl) + => !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : Url.Action("Index", "TwoFactor", new { area = "User" }); + + private Task AuditReauthenticationAsync(bool successful, string detail, CancellationToken cancellationToken) + { + return _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.AccountReauthenticated, + UserId = UserId, + TargetUserId = UserId, + Successful = successful, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + CorrelationId = HttpContext.TraceIdentifier, + Data = $"Password reauthentication{(detail == null ? string.Empty : $" ({detail})")}. Agent={BoundAuditValue(Request.Headers.UserAgent.ToString(), 256)}", + LoggedOn = DateTime.UtcNow + }, cancellationToken); + } + private async Task IsSsoManagedAsync(CancellationToken cancellationToken) { var state = await _externalIdentityLinkService.GetSsoManagementStateAsync(UserId, cancellationToken); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 3917b17ed..652ffc6b4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -121,7 +121,8 @@ public async Task Index() { var tree = new BSTreeModel(); tree.id = $"TreeGroup_{category.ContactCategoryId.ToString()}"; - tree.text = category.Name; + // bstreeview appends node text as HTML, so the user-entered category name must be encoded. + tree.text = System.Net.WebUtility.HtmlEncode(category.Name); tree.icon = ""; trees.Add(tree); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs index 38da91d51..29c157613 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs @@ -67,9 +67,15 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS IProtectedDataBrokerClient brokerClient, IDepartmentsService departmentsService, UserManager userManager, IProtectedDataGrantService grantService, IAdpReleaseService adpRelease, Resgrid.Model.Repositories.IAdpAccessStore adpAccess, Resgrid.Model.Repositories.IAdpAuditRepository adpAudit, ICacheProvider cacheProvider, IEventAggregator eventAggregator, IProtectedWorkflowService protectedWorkflows, - IChatbotDepartmentConfigService chatbotConfig) + IChatbotDepartmentConfigService chatbotConfig, IMfaEvidenceService mfaEvidence, IMfaActivityService mfaActivity, + IAdpStepUpService adpStepUp, IMfaCredentialStateService credentialStates, IMfaApprovalService approvals) { + _approvals = approvals; + _adpStepUp = adpStepUp; + _credentialStates = credentialStates; + _mfaActivity = mfaActivity; _chatbotConfig = chatbotConfig; + _mfaEvidence = mfaEvidence; _protectedWorkflows = protectedWorkflows; _eventAggregator = eventAggregator; _dataProtectionService = dataProtectionService; @@ -85,6 +91,12 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS _cacheProvider = cacheProvider; } + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaActivityService _mfaActivity; + private readonly IAdpStepUpService _adpStepUp; + private readonly IMfaApprovalService _approvals; + private readonly IMfaCredentialStateService _credentialStates; + [HttpGet] public async Task ReleaseSettings() { @@ -103,7 +115,9 @@ public async Task SaveReleaseSettings(int smsMode, int voiceMode, !acknowledged && (smsMode != 0 || voiceMode != 0 || supportEnabled)) return Unauthorized(); var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId, bypassCache: true); if (policy == null || _grantService.ValidateGrant(grantToken, DepartmentId, policy.PolicyEpoch, ProtectedDataGrantScopes.Read, - out var grant) != ProtectedDataGrantValidationOutcome.Valid || grant.UserId != UserId || grant.StepUpExempt || + out var grant) != ProtectedDataGrantValidationOutcome.Valid || grant.UserId != UserId || + await Resgrid.Services.ProtectedGrantBinding.CheckAsync(grant, UserId, HttpProtectedGrantContext.SessionOf(HttpContext), policy.StepUpWindowMinutes, + _credentialStates) != Resgrid.Model.Security.ProtectedGrantBindingOutcome.Bound || grant.StepUpExempt || grant.MfaAtUtc < DateTime.UtcNow.AddMinutes(-5) || grant.MfaAtUtc > DateTime.UtcNow.AddSeconds(30)) return Unauthorized(); await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "application", Operation = "release-settings", Outcome = "requested", ActorId = UserId, PolicyEpoch = policy.PolicyEpoch }); @@ -259,7 +273,7 @@ public async Task MigrationProgress(CancellationToken cancellatio /// [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor(RequireForOperation = true)] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.Adp)] public async Task QueueEnrollment([FromForm] QueueEnrollmentInputModel input, CancellationToken cancellationToken) { @@ -305,7 +319,7 @@ public async Task QueueEnrollment([FromForm] QueueEnrollmentInput [HttpPost] [ValidateAntiForgeryToken] [AllowDuringDepartmentLock] - [RequiresRecentTwoFactor(RequireForOperation = true)] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.Adp)] public async Task CancelQueuedEnrollment(CancellationToken cancellationToken) { var outcome = await _dataProtectionService.CancelQueuedEnrollmentAsync(DepartmentId, UserId, cancellationToken); @@ -319,7 +333,7 @@ public async Task CancelQueuedEnrollment(CancellationToken cancel [HttpPost] [ValidateAntiForgeryToken] [AllowDuringDepartmentLock] - [RequiresRecentTwoFactor(RequireForOperation = true)] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.Adp)] public async Task RevokeOffboarding(CancellationToken cancellationToken) { var outcome = await _dataProtectionService.RevokeOffboardingAsync(DepartmentId, UserId, cancellationToken); @@ -345,7 +359,7 @@ public async Task RevokeOffboarding(CancellationToken cancellatio /// [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor(RequireForOperation = true)] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.Adp)] public async Task SaveStepUpExemptions([FromForm] int exemptions, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -391,47 +405,19 @@ public async Task SaveStepUpExemptions([FromForm] int exemptions, [AllowDuringDepartmentLock] public async Task RequestGrant() { - // The exemption answer and the epoch stamped on the grant come from ONE policy snapshot. - // Read separately, a managing member revoking the Web exemption between the two reads - // would have the check pass against the old policy while the grant took the epoch that - // revocation bumped — leaving a step-up-exempt grant alive after the revocation. - var decision = await _dataProtectionService.GetStepUpDecisionForClientAsync(DepartmentId, - UserSessionClientApplication.Web); - - if (decision.StepUpRequired) - return Json(new { success = false, error = "step_up_required" }); - - if (!_grantService.CanIssueGrants) - return Json(new { success = false, error = "grants_not_configured" }); - - var windowMinutes = decision.StepUpWindowMinutes > 0 - ? decision.StepUpWindowMinutes - : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; - windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); - - var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + // The exemption answer and the epoch stamped on the grant come from one policy snapshot inside the issuer, so a + // revocation between two reads cannot leave a step-up-exempt grant alive after it. + var caller = StepUpCaller(await _userManager.FindByIdAsync(UserId)); + var issued = await _adpStepUp.IssueExemptAsync(caller); + if (issued.Outcome == Model.Security.AdpGrantOutcome.StepUpRequired) { - UserId = UserId, - DepartmentId = DepartmentId, - SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, - ClientApp = (int)UserSessionClientApplication.Web, - PolicyEpoch = decision.PolicyEpoch, - WindowMinutes = windowMinutes, - Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, - MfaAtUtc = DateTime.UtcNow, - StepUpExempt = true - }); - await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", - Operation = "grant-issued", Outcome = "step-up-exempt", ActorId = UserId, CorrelationId = issued.GrantId }); + // This session's own recent sign-in or unlock MFA, where the department accepts reusing it (plan section 9.1). + var reused = await _adpStepUp.IssueFromRecentEvidenceAsync(caller); + if (reused.Succeeded) + issued = reused; + } - return Json(new - { - success = true, - grantToken = issued.Token, - grantId = issued.GrantId, - expiresOnUtc = issued.ExpiresOnUtc.ToString("O"), - windowMinutes - }); + return GrantJson(issued); } [HttpPost] @@ -453,46 +439,149 @@ public async Task VerifyStepUp([FromForm] string code) if (!await _userManager.GetTwoFactorEnabledAsync(user)) return Json(new { success = false, error = "mfa_not_enrolled" }); + // ADP step-up shares the account lockout with sign-in and every other TOTP surface (passkey plan section 7.5 rule 6). + if (await _userManager.IsLockedOutAsync(user)) + return Json(new { success = false, error = "too_many_attempts" }); + var valid = await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code.Trim()); await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", Operation = "mfa-verify", Outcome = valid ? "verified" : "denied", ActorId = UserId }); if (!valid) + { + await _userManager.AccessFailedAsync(user); + await _mfaActivity.RecordAsync(new Model.Security.MfaActivityEntry + { + UserId = user.Id, Method = Model.Security.MfaEvidenceMethod.Totp, Purpose = Model.Security.MfaEvidencePurpose.AdpStepUp, Successful = false, + ClientApplication = UserSessionClientApplication.Web, DepartmentId = DepartmentId, + SessionId = Model.Security.MfaEvidence.TrackedSessionId(MfaEvidenceSession.KeyFor(User, HttpContext)) + }); return Json(new { success = false, error = "invalid_totp" }); + } - var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); - var windowMinutes = policy?.StepUpWindowMinutes > 0 - ? policy.StepUpWindowMinutes - : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; - windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + await _userManager.ResetAccessFailedCountAsync(user); - if (!_grantService.CanIssueGrants) - return Json(new { success = false, error = "grants_not_configured" }); + // The verified code becomes AdpStepUp evidence and, through the one issuer every method shares, a grant whose + // expiry runs from this verification (passkey plan sections 8.1 and 9.2). + return GrantJson(await _adpStepUp.IssueForTotpAsync(StepUpCaller(user), DateTime.UtcNow)); + } - var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest - { - UserId = UserId, - DepartmentId = DepartmentId, - SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, - ClientApp = (int)UserSessionClientApplication.Web, - PolicyEpoch = policy?.PolicyEpoch ?? 0, - WindowMinutes = windowMinutes, - Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, - MfaAtUtc = DateTime.UtcNow - }); - await _adpAudit.AppendAsync(new AdpAuditEvent { DepartmentId = DepartmentId, Layer = "identity", - Operation = "grant-issued", Outcome = "mfa-verified", ActorId = UserId, CorrelationId = issued.GrantId }); + /// + /// The ways this user can verify for this department's protected data now (passkey plan section 7.5 rule 5), for the + /// reveal dialog: the methods it has that the department accepts, and which to show first. Advisory only. + /// + [HttpGet] + [AllowDuringDepartmentLock] + public async Task StepUpMethods(CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Json(new { success = false, error = "protected_access_denied" }); + var choice = await _adpStepUp.GetMethodChoiceAsync(StepUpCaller(user), await _userManager.GetTwoFactorEnabledAsync(user), cancellationToken); return Json(new { success = true, - grantToken = issued.Token, - grantId = issued.GrantId, - expiresOnUtc = issued.ExpiresOnUtc.ToString("O"), - windowMinutes + methods = choice.AllowedMethods.Where(choice.EnrolledMethods.Contains).ToList(), + preferred = choice.Preferred }); } + /// Assertion options for a Web passkey, bound to this session and department's protected data. + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task PasskeyOptions(CancellationToken cancellationToken) + { + var start = await _adpStepUp.BeginPasskeyAsync(StepUpCaller(await _userManager.FindByIdAsync(UserId)), cancellationToken); + return start.Succeeded + ? Json(new { success = true, requestId = start.RequestId, options = start.OptionsJson }) + : Json(new { success = false, error = Model.Security.PasskeyOutcomes.ErrorCode(start.Outcome) }); + } + + /// Verifies the passkey and returns a passkey grant, held in the page's memory only. + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task VerifyPasskey([FromForm] string requestId, [FromForm] string credential, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(credential)) + return Json(new { success = false, error = "invalid_request" }); + + return GrantJson(await _adpStepUp.CompletePasskeyAsync(StepUpCaller(await _userManager.FindByIdAsync(UserId)), requestId, credential, + cancellationToken)); + } + + /// Asks the user's Responder to approve access to this department's protected data; returns the number to show. + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task RequestApproval(CancellationToken cancellationToken) + { + var start = await _adpStepUp.RequestApprovalAsync(StepUpCaller(await _userManager.FindByIdAsync(UserId)), cancellationToken); + return start.Succeeded + ? Json(new { success = true, approvalRequestId = start.ApprovalRequestId, matchNumber = start.MatchNumber, expiresIn = start.ExpiresInSeconds }) + : Json(new { success = false, error = Model.Security.MfaApprovalOutcomes.ErrorCode(start.Outcome) ?? "approval_unavailable" }); + } + + /// The state of this session's own approval request: pending, approved, denied, expired or canceled. + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task ApprovalStatus([FromForm] string approvalRequestId, CancellationToken cancellationToken) + { + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null) + return Json(new { success = false, error = Model.Security.MfaApprovalOutcomes.ErrorCode(Model.Security.MfaApprovalOutcome.SessionRequired) }); + + var found = await _approvals.GetForRequesterAsync(approvalRequestId, Model.Security.MfaApprovalRequesterKind.Session, session.SessionId, + cancellationToken); + return found.Succeeded + ? Json(new { success = true, state = Model.Security.MfaApprovalOutcomes.StateName(found.Request.EffectiveState(DateTime.UtcNow)) }) + : Json(new { success = false, error = Model.Security.MfaApprovalOutcomes.ErrorCode(found.Outcome) ?? "approval_unavailable" }); + } + + /// Uses the approved request once and returns a passkey_approval grant. + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task CompleteApproval([FromForm] string approvalRequestId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(approvalRequestId)) + return Json(new { success = false, error = "invalid_request" }); + + return GrantJson(await _adpStepUp.CompleteApprovalAsync(StepUpCaller(await _userManager.FindByIdAsync(UserId)), approvalRequestId, + cancellationToken)); + } + + /// The caller as the grant issuer sees it: this user, department and validated Web session (never client-supplied). + private Model.Security.AdpStepUpCaller StepUpCaller(IdentityUser user = null) => new() + { + UserId = UserId, + UserName = user?.UserName, + DepartmentId = DepartmentId, + Session = HttpProtectedGrantContext.SessionOf(HttpContext), + LegacySessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApplication = UserSessionClientApplication.Web, + AccountAuthenticationGeneration = user?.AuthenticationGeneration ?? 0, + EvidenceSessionKey = MfaEvidenceSession.KeyFor(User, HttpContext), + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + AuditSystem = SystemAuditSystems.Website + }; + + /// The reveal module's JSON shape: a grant held in page memory only, or a value-free error code. + private IActionResult GrantJson(Model.Security.AdpGrantIssue issued) => + issued.Succeeded + ? Json(new + { + success = true, + grantToken = issued.Token, + grantId = issued.GrantId, + expiresOnUtc = issued.ExpiresOnUtc.ToString("O"), + windowMinutes = issued.WindowMinutes + }) + : Json(new { success = false, error = issued.ErrorCode }); + private IActionResult MapOutcome(DepartmentDataProtectionEnrollmentResult outcome) { if (outcome == DepartmentDataProtectionEnrollmentResult.Queued) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PasskeysController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PasskeysController.cs new file mode 100644 index 000000000..5f448b873 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/PasskeysController.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; +using Resgrid.Model.Security; +using Resgrid.Model.Services; +using Resgrid.Web.Helpers; + + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Passkeys for Core Web on the account security page (passkey plan sections 6.1 and 6.5): add a passkey bound to Web, + /// rename and remove any of the user's passkeys in any app. Every command goes through the same passkey service as the + /// API, which rechecks the rollout gate, the Web relying party and this session's server-side evidence; the page only + /// forwards the browser's ceremony. JSON only, antiforgery on every post. + /// + [Area("User")] + [Microsoft.AspNetCore.Authorization.Authorize] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): signing in + // and out, locking and unlocking a shared session, and verifying a second factor touch no department data. + [Resgrid.Web.Filters.AllowDuringDepartmentLock] + public class PasskeysController : SecureBaseController + { + private readonly IPasskeyService _passkeys; + private readonly UserManager _userManager; + + public PasskeysController(IPasskeyService passkeys, UserManager userManager) + { + _passkeys = passkeys; + _userManager = userManager; + } + + /// Creation options for a new Web passkey; needs a recent password (or SSO) confirmation and second factor. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RegistrationOptions(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + return Refuse(PasskeyOutcome.SessionRequired); + + var totpEnrolled = await _userManager.GetTwoFactorEnabledAsync(user); + var codesLeft = totpEnrolled ? await _userManager.CountRecoveryCodesAsync(user) : 0; + var start = await _passkeys.BeginRegistrationAsync(Caller(user), totpEnrolled, codesLeft, cancellationToken); + return start.Succeeded + ? Json(new { success = true, requestId = start.RequestId, options = start.OptionsJson }) + : Refuse(start.Outcome); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CompleteRegistration([FromForm] string requestId, [FromForm] string credential, [FromForm] string displayName, + CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + return Refuse(PasskeyOutcome.SessionRequired); + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(credential)) + return Refuse(PasskeyOutcome.InvalidRequest); + + var registered = await _passkeys.CompleteRegistrationAsync(Caller(user), requestId, credential, displayName, cancellationToken); + return registered.Outcome == PasskeyOutcome.Succeeded ? Json(new { success = true }) : Refuse(registered.Outcome); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Rename([FromForm] string id, [FromForm] string displayName, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + return Refuse(PasskeyOutcome.SessionRequired); + + var outcome = await _passkeys.RenameAsync(Caller(user), id, displayName, cancellationToken); + return outcome == PasskeyOutcome.Succeeded ? Json(new { success = true }) : Refuse(outcome); + } + + /// Removes one of the user's passkeys, in any app; needs the authenticator or a Web passkey within five minutes. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Remove([FromForm] string id, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + return Refuse(PasskeyOutcome.SessionRequired); + + var removed = await _passkeys.RevokeAsync(Caller(user), id, cancellationToken); + return removed.Outcome == PasskeyOutcome.Succeeded + ? Json(new { success = true, signedOut = removed.CurrentSessionEnded }) + : Refuse(removed.Outcome); + } + + private PasskeyCaller Caller(IdentityUser user) => + PasskeyCaller.From(HttpProtectedGrantContext.SessionOf(HttpContext), user.Id, user.UserName, DepartmentId, SystemAuditSystems.Website, + IpAddressHelper.GetRequestIP(Request, true)); + + /// + /// A value-free code, and where the page sends the user when the answer is "prove who you are first": the password + /// (or SSO) confirmation, or a fresh second factor, each returning to the account security page. + /// + private IActionResult Refuse(PasskeyOutcome outcome) + { + var back = Url.Action("Index", "TwoFactor", new { area = "User" }); + var redirect = outcome switch + { + PasskeyOutcome.ReauthenticationRequired => Url.Action("Reauthenticate", "AccountSecurity", new { area = "User", returnUrl = back }), + PasskeyOutcome.StepUpRequired => Url.Action("Verify2FA", "TwoFactor", new { area = "User", returnUrl = back }), + PasskeyOutcome.SessionRequired => Url.Action("LogOn", "Account", new { area = "" }), + _ => null + }; + return Json(new { success = false, error = PasskeyOutcomes.ErrorCode(outcome), redirect }); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 5717c0881..9e877411f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -322,31 +322,7 @@ public async Task Index() tree1.icon = ""; trees.Add(tree1); - if (model.Groups != null && model.Groups.Any()) - { - foreach (var topLevelGroup in model.Groups.Where(x => !x.ParentDepartmentGroupId.HasValue).ToList()) - { - var group = new BSTreeModel(); - group.id = $"TreeGroup_{topLevelGroup.DepartmentGroupId.ToString()}"; - group.text = topLevelGroup.Name; - group.icon = ""; - - if (topLevelGroup.Children != null && topLevelGroup.Children.Any()) - { - foreach (var secondLevelGroup in topLevelGroup.Children) - { - var secondLevelGroupTree = new BSTreeModel(); - secondLevelGroupTree.id = $"TreeGroup_{secondLevelGroup.DepartmentGroupId.ToString()}"; - secondLevelGroupTree.text = secondLevelGroup.Name; - secondLevelGroupTree.icon = ""; - - group.nodes.Add(secondLevelGroupTree); - } - } - - trees.Add(group); - } - } + trees.AddRange(BSTreeModel.ForDepartmentGroups(model.Groups)); model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 0739f1136..24d0ff935 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -68,6 +68,8 @@ public class ProfileController : SecureBaseController private readonly IBusinessOperationsAccessService _businessOperationsAccess; private readonly ILimitsService _limitsService; private readonly IStringLocalizer _profileLocalizer; + private readonly IMfaPolicyService _mfaPolicyService; + private readonly IMfaEvidenceService _mfaEvidenceService; public ProfileController(IDepartmentsService departmentsService, IUsersService usersService, Model.Services.IAuthorizationService authorizationService, IUserProfileService userProfileService, IScheduledTasksService scheduledTasksService, ICertificationService certificationService, @@ -79,8 +81,12 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u ISystemAuditsService systemAuditsService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IPasswordRecoveryService passwordRecoveryService, IEventAggregator eventAggregator, IProtectedReadService protectedReadService, IBusinessOperationsAccessService businessOperationsAccess, - ILimitsService limitsService, IStringLocalizer profileLocalizer) + ILimitsService limitsService, IStringLocalizer profileLocalizer, + IMfaPolicyService mfaPolicyService, IMfaEvidenceService mfaEvidenceService, ISsoBrokerService ssoBroker, + ISsoReturnTargetRegistry ssoReturnTargets) { + _ssoBroker = ssoBroker; + _ssoReturnTargets = ssoReturnTargets; _departmentsService = departmentsService; _usersService = usersService; _authorizationService = authorizationService; @@ -107,6 +113,8 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u _businessOperationsAccess = businessOperationsAccess; _limitsService = limitsService; _profileLocalizer = profileLocalizer; + _mfaPolicyService = mfaPolicyService; + _mfaEvidenceService = mfaEvidenceService; } #endregion Private Members and Constructors @@ -1618,7 +1626,12 @@ public async Task SetActiveDepartment([FromBody]ChangeActiveDepa { if (await _departmentsService.IsMemberOfDepartmentAsync(model.DepartmentId, UserId)) { - if (!await CanContinueCurrentAuthenticationInDepartmentAsync(model.DepartmentId, cancellationToken)) + var entry = await CheckDepartmentEntryAsync(model.DepartmentId, cancellationToken); + if (entry == DepartmentEntry.StepUpRequired) + return StatusCode(StatusCodes.Status403Forbidden, new { error = "step_up_required", redirectUrl = StepUpThenYourDepartmentsUrl(model.DepartmentId) }); + if (entry == DepartmentEntry.SsoRequired && await SsoSignInForDepartmentUrlAsync(model.DepartmentId) is string ssoUrl) + return StatusCode(StatusCodes.Status403Forbidden, new { error = "sso_required", redirectUrl = ssoUrl }); + if (entry != DepartmentEntry.Allowed) return Forbid(); var user = await _userManager.FindByIdAsync(UserId); @@ -1640,7 +1653,12 @@ public async Task SetDefaultDepartment(int departmentId, Cancell { if (await _departmentsService.IsMemberOfDepartmentAsync(departmentId, UserId)) { - if (!await CanContinueCurrentAuthenticationInDepartmentAsync(departmentId, cancellationToken)) + var entry = await CheckDepartmentEntryAsync(departmentId, cancellationToken); + if (entry == DepartmentEntry.StepUpRequired) + return Redirect(StepUpThenYourDepartmentsUrl(departmentId)); + if (entry == DepartmentEntry.SsoRequired && await SsoSignInForDepartmentUrlAsync(departmentId) is string ssoUrl) + return Redirect(ssoUrl); + if (entry != DepartmentEntry.Allowed) return Forbid(); var user = await _userManager.FindByIdAsync(UserId); @@ -1677,9 +1695,10 @@ public async Task DeleteDepartmentLink(int departmentId, Cancella .OrderByDescending(x => x.IsDefault) .FirstOrDefault(); var switchesDepartment = departmentToRemove.IsActive; + // Leaving the active department moves the session to the remaining one; if this session may not enter it + // (its SSO, or MFA it has not completed), the user signs in again there instead. var canKeepCurrentSession = !switchesDepartment || - await CanContinueCurrentAuthenticationInDepartmentAsync(remainingDepartment.DepartmentId, - cancellationToken); + await CheckDepartmentEntryAsync(remainingDepartment.DepartmentId, cancellationToken) == DepartmentEntry.Allowed; if (switchesDepartment) { @@ -1718,6 +1737,60 @@ await _departmentsService.SetDefaultDepartmentForUserAsync(UserId, remainingDepa : RedirectToAction("YourDepartments"); } + private enum DepartmentEntry + { + Allowed, + + /// The department requires its own SSO sign-in. + SsoRequired, + + /// The department requires MFA and this session has not completed it (passkey plan section 7.6 row 5). + StepUpRequired + } + + private async Task CheckDepartmentEntryAsync(int departmentId, CancellationToken cancellationToken) + { + if (!await CanContinueCurrentAuthenticationInDepartmentAsync(departmentId, cancellationToken)) + return DepartmentEntry.SsoRequired; + + // A RequireMfa department accepts only a session that has completed an actual second factor. An unenrolled + // member may enter, and the enrollment middleware then confines them to setup until they enroll. + if (!await _mfaPolicyService.IsRequireMfaEnforcedAsync(departmentId, cancellationToken)) + return DepartmentEntry.Allowed; + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null || !await _userManager.GetTwoFactorEnabledAsync(user)) + return DepartmentEntry.Allowed; + + return await StepUpEvidence.GetLatestSecondFactorUtcAsync(_mfaEvidenceService, user, HttpContext, _mfaPolicyService, departmentId, + MfaMethodScope.Login, cancellationToken) == null + ? DepartmentEntry.StepUpRequired + : DepartmentEntry.Allowed; + } + + private string StepUpThenYourDepartmentsUrl(int departmentId) => + Url.Action("Verify2FA", "TwoFactor", new { area = "User", returnUrl = Url.Action("YourDepartments", "Profile", new { area = "User" }), + entry = departmentId }); + + private readonly ISsoBrokerService _ssoBroker; + private readonly ISsoReturnTargetRegistry _ssoReturnTargets; + + /// + /// A department that requires its own SSO is entered by signing in through its provider (plan section 7.6 row 5): the Web SSO + /// sign-in for that department, when this deployment offers it; null otherwise. + /// + private async Task SsoSignInForDepartmentUrlAsync(int departmentId) + { + if (!Resgrid.Config.TwoFactorConfig.WebLoginMfaTransactionEnabled || !Resgrid.Config.TwoFactorConfig.LoginMfaTransactionEnabled || + !WebSsoRoundTrip.IsAvailable(_ssoBroker, _ssoReturnTargets)) + return null; + + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); + return string.IsNullOrWhiteSpace(department?.Code) + ? null + : Url.Action("SsoLogOn", "Account", new { area = "", departmentCode = department.Code, returnUrl = Url.Action("Dashboard", "Home", new { area = "User" }) }); + } + private async Task CanContinueCurrentAuthenticationInDepartmentAsync(int departmentId, CancellationToken cancellationToken) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProtectedWorkflowsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProtectedWorkflowsController.cs index b14f02878..874573c7d 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProtectedWorkflowsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProtectedWorkflowsController.cs @@ -30,14 +30,22 @@ public class ProtectedWorkflowsController : SecureBaseController private readonly IWorkflowService _workflowService; private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; + private readonly Microsoft.AspNetCore.Identity.UserManager _userManager; + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaPolicyService _mfaPolicy; public ProtectedWorkflowsController(IProtectedWorkflowService protectedWorkflows, IWorkflowService workflowService, - IDepartmentsService departmentsService, IUserProfileService userProfileService) + IDepartmentsService departmentsService, IUserProfileService userProfileService, + Microsoft.AspNetCore.Identity.UserManager userManager, IMfaEvidenceService mfaEvidence, + IMfaPolicyService mfaPolicy) { + _mfaPolicy = mfaPolicy; _protectedWorkflows = protectedWorkflows; _workflowService = workflowService; _departmentsService = departmentsService; _userProfileService = userProfileService; + _userManager = userManager; + _mfaEvidence = mfaEvidence; } // ── Pages ───────────────────────────────────────────────────────────────────────────────────── @@ -154,12 +162,12 @@ public async Task Panel(string workflowId, CancellationToken canc /// Sends the user through the step-up verification and back to the page they came from. [HttpGet] - [RequiresRecentTwoFactor(RequireForOperation = true)] - public IActionResult StepUp(string returnUrl) + [RequiresRecentTwoFactor(RequireForOperation = true, MethodScope = Resgrid.Model.Security.MfaMethodScope.Adp)] + public async Task StepUp(string returnUrl) { // The attribute's window can be longer than the one Protected Workflows accept; re-verify rather than bounce the // user back to a command the service will refuse again. - if (!ProtectedWorkflowService.IsStepUpFresh(RequiresRecentTwoFactorAttribute.GetStepUpVerifiedAtUtc(HttpContext, UserId), DateTime.UtcNow)) + if (!ProtectedWorkflowService.IsStepUpFresh(await StepUpVerifiedAtUtcAsync(), DateTime.UtcNow)) return RedirectToAction("Verify2FA", "TwoFactor", new { area = "User", returnUrl = Url.Action("StepUp", new { returnUrl }) }); if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)) @@ -176,7 +184,7 @@ public async Task SaveDepartmentSettings([FromBody] ProtectedWork if (input == null) return BadRequest(); return Result(await _protectedWorkflows.SetDepartmentSettingsAsync(DepartmentId, input.Enabled, input.RequireSecondApprover, - input.AcknowledgedVersion, Actor(), cancellationToken)); + input.AcknowledgedVersion, await ActorAsync(), cancellationToken)); } [HttpPost] @@ -191,7 +199,7 @@ public async Task SaveDraft([FromBody] ProtectedReleaseDraftInput RecipientType = input.RecipientType, RecipientName = input.RecipientName, Purpose = input.Purpose - }, Actor(), cancellationToken)); + }, await ActorAsync(), cancellationToken)); } [HttpPost] @@ -201,7 +209,7 @@ public async Task RequestApproval([FromBody] ProtectedReleaseComm if (input == null) return BadRequest(); return Result(await _protectedWorkflows.RequestApprovalAsync(DepartmentId, input.WorkflowId, input.Attested, - input.AcknowledgedVersion, input.ReviewedFingerprint, Actor(), input.Sensitive(), cancellationToken)); + input.AcknowledgedVersion, input.ReviewedFingerprint, await ActorAsync(), input.Sensitive(), cancellationToken)); } [HttpPost] @@ -211,7 +219,7 @@ public async Task Approve([FromBody] ProtectedReleaseCommandInput if (input == null) return BadRequest(); return Result(await _protectedWorkflows.ApproveAsync(DepartmentId, input.ReleaseId, input.Attested, - input.AcknowledgedVersion, input.ReviewedFingerprint, Actor(), input.Sensitive(), cancellationToken)); + input.AcknowledgedVersion, input.ReviewedFingerprint, await ActorAsync(), input.Sensitive(), cancellationToken)); } [HttpPost] @@ -221,23 +229,23 @@ public async Task Renew([FromBody] ProtectedReleaseCommandInput i if (input == null) return BadRequest(); return Result(await _protectedWorkflows.RenewAsync(DepartmentId, input.ReleaseId, input.Attested, - input.AcknowledgedVersion, input.ReviewedFingerprint, Actor(), input.Sensitive(), cancellationToken)); + input.AcknowledgedVersion, input.ReviewedFingerprint, await ActorAsync(), input.Sensitive(), cancellationToken)); } [HttpPost] [ValidateAntiForgeryToken] public async Task Suspend([FromBody] ProtectedReleaseCommandInput input, CancellationToken cancellationToken) => - input == null ? BadRequest() : Result(await _protectedWorkflows.SuspendAsync(DepartmentId, input.ReleaseId, Actor(), cancellationToken)); + input == null ? BadRequest() : Result(await _protectedWorkflows.SuspendAsync(DepartmentId, input.ReleaseId, await ActorAsync(), cancellationToken)); [HttpPost] [ValidateAntiForgeryToken] public async Task Revoke([FromBody] ProtectedReleaseCommandInput input, CancellationToken cancellationToken) => - input == null ? BadRequest() : Result(await _protectedWorkflows.RevokeAsync(DepartmentId, input.ReleaseId, Actor(), cancellationToken)); + input == null ? BadRequest() : Result(await _protectedWorkflows.RevokeAsync(DepartmentId, input.ReleaseId, await ActorAsync(), cancellationToken)); [HttpPost] [ValidateAntiForgeryToken] public async Task DiscardDraft([FromBody] ProtectedReleaseCommandInput input, CancellationToken cancellationToken) => - input == null ? BadRequest() : Result(await _protectedWorkflows.DiscardDraftAsync(DepartmentId, input.ReleaseId, Actor(), cancellationToken)); + input == null ? BadRequest() : Result(await _protectedWorkflows.DiscardDraftAsync(DepartmentId, input.ReleaseId, await ActorAsync(), cancellationToken)); /// "Send test with sample data": synthetic values only, recorded as test disclosures. [HttpPost] @@ -263,13 +271,18 @@ public async Task SendTest([FromBody] ProtectedReleaseCommandInpu // ── Helpers ─────────────────────────────────────────────────────────────────────────────────── /// A signed-in web session is interactive; the step-up time comes from this session's verified second factor. - private ProtectedWorkflowActor Actor() => new ProtectedWorkflowActor + private async Task ActorAsync() => new ProtectedWorkflowActor { UserId = UserId, IsInteractive = true, - StepUpVerifiedAtUtc = RequiresRecentTwoFactorAttribute.GetStepUpVerifiedAtUtc(HttpContext, UserId) + StepUpVerifiedAtUtc = await StepUpVerifiedAtUtcAsync() }; + /// This session's latest actual second factor, from server-side evidence (passkey plan section 7.6 row 11). + private async Task StepUpVerifiedAtUtcAsync() => + await RequiresRecentTwoFactorAttribute.GetStepUpVerifiedAtUtcAsync(HttpContext, await _userManager.FindByIdAsync(UserId), _mfaEvidence, + _mfaPolicy, DepartmentId, Resgrid.Model.Security.MfaMethodScope.Adp); + private IActionResult Result(ProtectedWorkflowCommandResult result) => Json(new { success = result.Success, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs index 148ea522d..995ea6687 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SearchController.cs @@ -1,36 +1,160 @@ +using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; +using Resgrid.Config; using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Search; using Resgrid.Model.Services; using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Models.Records; using Resgrid.Web.Helpers; using Resgrid.WebCore.Areas.User.Models.Search; namespace Resgrid.Web.Areas.User.Controllers { /// - /// The command palette behind the top search box (Unified Search plan R3 "Action" family + R4 Phase 2). System - /// functionality always comes from the catalog, filtered by the caller's claims, module switches and feature flags; - /// entity hits come from the unified endpoint when Search.Unified is on for the department, each re-checked against - /// the entity's own authorization rule before it is returned. + /// The command palette behind the top search box (Unified Search plan R3 "Action" family + R4 Phase 2) and the full + /// search page. System functionality always comes from the catalog, filtered by the caller's claims, module switches + /// and feature flags; entity hits come from the unified endpoint when Search.Unified is on for the department, each + /// re-checked against the entity's own authorization rule before it is returned. The page narrows by family and date + /// and exports every authorized match, so a question such as "every call whose notes mention this alarm point" ends in + /// a complete list. /// [Area("User")] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public class SearchController : SecureBaseController { + private const int PageSize = 25; + + /// Display order of the family filter; anything else the service reports follows in its own order. + private static readonly string[] FamilyOrder = + { + SearchEntityTypes.Call, SearchEntityTypes.Record, SearchEntityTypes.Log, SearchEntityTypes.Personnel, SearchEntityTypes.Unit, + SearchEntityTypes.Contact, SearchEntityTypes.Occupancy, SearchEntityTypes.Poi, SearchEntityTypes.Group, SearchEntityTypes.Message, SearchEntityTypes.Note, + SearchEntityTypes.Document, SearchEntityTypes.Protocol, SearchEntityTypes.Training, SearchEntityTypes.CalendarEvent, SearchEntityTypes.Shift, + SearchEntityTypes.Deployment, SearchEntityTypes.Invoice, SearchEntityTypes.Bid, SearchEntityTypes.ServiceContract, SearchEntityTypes.RateCard, + SearchEntityTypes.CertificationType + }; + + private static readonly string[] ExportColumns = { "Type", "Title", "Number", "Date", "Status", "Category", "Summary", "Match", "Link" }; + private readonly IUnifiedSearchService _unifiedSearch; private readonly ISystemActionsService _systemActions; + private readonly IDepartmentsService _departmentsService; - public SearchController(IUnifiedSearchService unifiedSearch, ISystemActionsService systemActions) + public SearchController(IUnifiedSearchService unifiedSearch, ISystemActionsService systemActions, IDepartmentsService departmentsService) { _unifiedSearch = unifiedSearch; _systemActions = systemActions; + _departmentsService = departmentsService; + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task Index(string q, string type, string from, string to, string sort, int page = 1, CancellationToken cancellationToken = default) + { + var principal = BuildPrincipal(); + var model = new SearchIndexView + { + Query = (q ?? string.Empty).Trim(), + From = from, + To = to, + Sort = SearchSortOrders.Normalize(sort), + Page = Math.Max(1, page), + PageSize = PageSize, + MaxExportRows = Math.Max(1, SearchConfig.MaxPageWindow) + }; + + model.Families = Ordered(await _unifiedSearch.GetSearchableEntityTypesAsync(principal, cancellationToken)); + if (model.Families.Count == 0) + { + model.Unavailable = true; + return View(model); + } + model.Type = model.Families.FirstOrDefault(f => string.Equals(f, type, StringComparison.OrdinalIgnoreCase)); + + if (model.Query.Length == 0) + return View(model); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var (fromUtc, toUtc, invalid) = Range(from, to, department); + model.InvalidDate = invalid; + model.Searched = true; + model.HighlightTerms = SearchSnippets.Terms(model.Query); + + UnifiedSearchResult result; + try + { + // One extra hit tells whether a next page exists when the total cannot be proven. + result = await _unifiedSearch.SearchAsync(BuildRequest(model.Query, model.Type, fromUtc, toUtc, model.Sort, + (model.Page - 1) * PageSize, PageSize + 1), principal, cancellationToken); + } + catch (Exception ex) + { + // The query text stays out of the log: it can name protected people, addresses and record numbers. + Logging.LogException(ex, $"Search page query failed (department {DepartmentId}, user {UserId}, query of {model.Query.Length} chars)."); + model.Degraded = true; + return View(model); + } + + if (!result.Available) + { + model.Unavailable = true; + return View(model); + } + + model.Degraded = result.Degraded; + model.IndexBuilding = result.IndexBuilding; + model.Total = result.Total; + model.HasMore = result.Hits.Count > PageSize; + model.Results = result.Hits.Take(PageSize).Select(h => Row(h, department)).ToList(); + return View(model); + } + + /// Every authorized match of the query (up to SearchConfig.MaxPageWindow) as CSV. + [HttpGet] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task Export(string q, string type, string from, string to, string sort, CancellationToken cancellationToken = default) + { + var text = (q ?? string.Empty).Trim(); + if (text.Length == 0) + return RedirectToAction("Index"); + + var principal = BuildPrincipal(); + var families = await _unifiedSearch.GetSearchableEntityTypesAsync(principal, cancellationToken); + if (families.Count == 0) + return Unauthorized(); + var family = families.FirstOrDefault(f => string.Equals(f, type, StringComparison.OrdinalIgnoreCase)); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var (fromUtc, toUtc, _) = Range(from, to, department); + var max = Math.Max(1, SearchConfig.MaxPageWindow); + var result = await _unifiedSearch.SearchAsync(BuildRequest(text, family, fromUtc, toUtc, SearchSortOrders.Normalize(sort), 0, max, max), + principal, cancellationToken); + if (!result.Available) + return Unauthorized(); + + var csv = new StringBuilder(); + csv.Append(string.Join(",", ExportColumns)).Append("\r\n"); + foreach (var row in result.Hits.Select(h => Row(h, department))) + { + csv.Append(string.Join(",", new[] + { + row.EntityType, row.Title, row.Number, row.OccurredOn, row.Status, row.Category, row.Summary, row.Snippet, row.Url + }.Select(RecordsListExport.Escape))).Append("\r\n"); + } + + var name = $"search-{(family ?? "all").ToLowerInvariant()}-{DateTime.UtcNow:yyyyMMdd-HHmm}.csv"; + return File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray(), "text/csv", name); } [HttpGet] @@ -44,13 +168,16 @@ public async Task GetSearchResults(string query, CancellationToke UnifiedSearchResult unified = null; try { + // Full-text mode: every word must match, the last one may be partial, and the summary and full text (call + // notes included) count — prefix mode only looked at titles and identifiers. unified = await _unifiedSearch.SearchAsync(new UnifiedSearchRequest { Text = text, Take = 10, - Prefix = true, + Prefix = false, IncludeActions = true, - IncludeRecords = false + IncludeRecords = false, + CountTotal = false }, principal, cancellationToken); } catch (System.Exception ex) @@ -86,8 +213,8 @@ public async Task GetSearchResults(string query, CancellationToke items.AddRange(unified.Hits.Select(h => new SearchResultJson { Label = h.Title, - Summary = string.IsNullOrWhiteSpace(h.Summary) ? Badge(h) : h.Summary, - Url = string.IsNullOrWhiteSpace(h.Url) ? null : (h.Url.StartsWith("http") ? h.Url : Config.SystemBehaviorConfig.ResgridBaseUrl + h.Url), + Summary = !string.IsNullOrWhiteSpace(h.Snippet) ? h.Snippet : string.IsNullOrWhiteSpace(h.Summary) ? Badge(h) : h.Summary, + Url = AbsoluteUrl(h.Url), Group = Plural(h.EntityType), Type = h.EntityType })); @@ -96,6 +223,86 @@ public async Task GetSearchResults(string query, CancellationToke return Content(JsonConvert.SerializeObject(items), "application/json"); } + private static UnifiedSearchRequest BuildRequest(string text, string family, DateTime? fromUtc, DateTime? toUtc, string sort, int skip, int take, int maxCandidates = 0) + { + // A single family or a date range is a narrowed question: authorize the deep window so the list and its count are + // complete. Every family at once stays on the default window. + var narrowed = family != null || fromUtc.HasValue || toUtc.HasValue; + return new UnifiedSearchRequest + { + Text = text, + EntityTypes = family == null ? null : new List { family }, + Skip = skip, + Take = take, + Prefix = false, + IncludeActions = false, + IncludeRecords = family == null || family == SearchEntityTypes.Record, + FromUtc = fromUtc, + ToUtc = toUtc, + Sort = sort, + MaxCandidates = maxCandidates > 0 ? maxCandidates : narrowed ? SearchConfig.MaxPageWindow : 0 + }; + } + + /// Department-local From/To dates (yyyy-MM-dd) to a UTC range: From at local midnight, To through the end of its local day. + private static (DateTime? fromUtc, DateTime? toUtc, bool invalid) Range(string from, string to, Department department) + { + var invalid = false; + DateTime? Date(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + if (DateTime.TryParseExact(value.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + return date.Date; + invalid = true; + return null; + } + DateTime Utc(DateTime local) + { + try { return DateTimeHelpers.ConvertToUtc(local, department?.TimeZone, true); } + catch (Exception) { return DateTime.SpecifyKind(local, DateTimeKind.Utc); } + } + + var fromDate = Date(from); + var toDate = Date(to); + if (fromDate.HasValue && toDate.HasValue && fromDate > toDate) + (fromDate, toDate) = (toDate, fromDate); + return (fromDate.HasValue ? Utc(fromDate.Value) : (DateTime?)null, + toDate.HasValue ? Utc(toDate.Value.AddDays(1).AddTicks(-1)) : (DateTime?)null, + invalid); + } + + private static List Ordered(IEnumerable families) + { + var list = (families ?? Enumerable.Empty()).Where(f => !string.IsNullOrWhiteSpace(f)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return list.OrderBy(f => { var i = Array.IndexOf(FamilyOrder, f); return i < 0 ? int.MaxValue : i; }).ToList(); + } + + private static SearchResultRow Row(UnifiedSearchHit hit, Department department) + { + string number = null; + hit.Metadata?.TryGetValue("Number", out number); + return new SearchResultRow + { + EntityType = hit.EntityType, + Title = hit.Title, + Url = AbsoluteUrl(hit.Url), + Summary = hit.Summary, + Snippet = hit.Snippet, + OccurredOn = hit.OccurredOn.HasValue && department != null ? hit.OccurredOn.Value.TimeConverterToString(department) : null, + Status = hit.Status, + Category = hit.Category, + Number = number + }; + } + + private static string AbsoluteUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + return url.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? url : Config.SystemBehaviorConfig.ResgridBaseUrl + url; + } + private SearchPrincipal BuildPrincipal() { var user = HttpContext?.User; @@ -154,6 +361,14 @@ private static string Plural(string entityType) case SearchEntityTypes.ServiceContract: return "Contracts"; case SearchEntityTypes.Deployment: return "Deployments"; case SearchEntityTypes.CertificationType: return "Certification Types"; + case SearchEntityTypes.Protocol: return "Protocols"; + case SearchEntityTypes.Training: return "Trainings"; + case SearchEntityTypes.CalendarEvent: return "Calendar"; + case SearchEntityTypes.Log: return "Logs"; + case SearchEntityTypes.Poi: return "Points of Interest"; + case SearchEntityTypes.Shift: return "Shifts"; + case SearchEntityTypes.Group: return "Groups & Stations"; + case SearchEntityTypes.Occupancy: return "Occupancies"; default: return entityType; } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index e17675bf9..e343c78b6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -44,6 +44,10 @@ public class SecurityController : SecureBaseController private readonly IEncryptionService _encryptionService; private readonly IRecordsCutoverService _recordsCutoverService; + private readonly IPasskeyFeatureGates _passkeyGates; + private readonly IMfaEvidenceService _mfaEvidence; + private readonly IMfaPolicyService _mfaPolicy; + public SecurityController(IDepartmentsService departmentsService, IAuditService auditService, IPermissionsService permissionsService, IEventAggregator eventAggregator, IDepartmentSettingsService departmentSettingsService, ISystemAuditsService systemAuditsService, @@ -51,8 +55,14 @@ public SecurityController(IDepartmentsService departmentsService, IAuditService IStringLocalizer secLocalizer, IDepartmentSsoService ssoService, IEncryptionService encryptionService, - IRecordsCutoverService recordsCutoverService) + IRecordsCutoverService recordsCutoverService, + IPasskeyFeatureGates passkeyGates, + IMfaEvidenceService mfaEvidence, + IMfaPolicyService mfaPolicy) { + _mfaEvidence = mfaEvidence; + _mfaPolicy = mfaPolicy; + _passkeyGates = passkeyGates; _departmentsService = departmentsService; _auditService = auditService; _permissionsService = permissionsService; @@ -87,21 +97,24 @@ public async Task Index() else model.CreateCall = 3; + // Option text is localized: the permission notes refer to these options by name in each language. + var optionLabels = PermissionOptionLabels.From(_secLocalizer); + var userAddPermissions = new List(); - userAddPermissions.Add(new { Id = 0, Name = "Department Admins" }); - userAddPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); + userAddPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + userAddPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); model.AddUserPermissions = new SelectList(userAddPermissions, "Id", "Name"); var userDeletePermissions = new List(); - userDeletePermissions.Add(new { Id = 0, Name = "Department Admins" }); - userDeletePermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); + userDeletePermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + userDeletePermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); model.RemoveUserPermissions = new SelectList(userDeletePermissions, "Id", "Name"); var createCallPermissions = new List(); - createCallPermissions.Add(new { Id = 3, Name = "Everyone" }); - createCallPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createCallPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createCallPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createCallPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createCallPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createCallPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createCallPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateCallPermissions = new SelectList(createCallPermissions, "Id", "Name"); @@ -109,9 +122,9 @@ public async Task Index() model.CreateTraining = permissions.First(x => x.PermissionType == (int)PermissionTypes.CreateTraining).Action; var createTrainingPermissions = new List(); - createTrainingPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createTrainingPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createTrainingPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createTrainingPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createTrainingPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createTrainingPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateTrainingPermissions = new SelectList(createTrainingPermissions, "Id", "Name"); @@ -121,10 +134,10 @@ public async Task Index() model.CreateDocument = 3; var createDocumentPermissions = new List(); - createDocumentPermissions.Add(new { Id = 3, Name = "Everyone" }); - createDocumentPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createDocumentPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createDocumentPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createDocumentPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createDocumentPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createDocumentPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createDocumentPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateDocumentPermissions = new SelectList(createDocumentPermissions, "Id", "Name"); @@ -134,10 +147,10 @@ public async Task Index() model.CreateCalendarEntry = 3; var createCalendarEntryPermissions = new List(); - createCalendarEntryPermissions.Add(new { Id = 3, Name = "Everyone" }); - createCalendarEntryPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createCalendarEntryPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createCalendarEntryPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createCalendarEntryPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createCalendarEntryPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createCalendarEntryPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createCalendarEntryPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateCalendarEntryPermissions = new SelectList(createCalendarEntryPermissions, "Id", "Name"); @@ -147,10 +160,10 @@ public async Task Index() model.CreateNote = 3; var createNotePermissions = new List(); - createNotePermissions.Add(new { Id = 3, Name = "Everyone" }); - createNotePermissions.Add(new { Id = 0, Name = "Department Admins" }); - createNotePermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createNotePermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createNotePermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createNotePermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createNotePermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createNotePermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateNotePermissions = new SelectList(createNotePermissions, "Id", "Name"); @@ -160,10 +173,10 @@ public async Task Index() model.CreateLog = 3; var createLogPermissions = new List(); - createLogPermissions.Add(new { Id = 3, Name = "Everyone" }); - createLogPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createLogPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createLogPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createLogPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createLogPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createLogPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createLogPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateLogPermissions = new SelectList(createLogPermissions, "Id", "Name"); @@ -173,10 +186,10 @@ public async Task Index() model.DeleteLog = 3; var deleteLogPermissions = new List(); - deleteLogPermissions.Add(new { Id = 3, Name = "Everyone" }); - deleteLogPermissions.Add(new { Id = 0, Name = "Department Admins" }); - deleteLogPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - deleteLogPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + deleteLogPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + deleteLogPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + deleteLogPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + deleteLogPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.DeleteLogPermissions = new SelectList(deleteLogPermissions, "Id", "Name"); @@ -184,9 +197,9 @@ public async Task Index() model.CreateShift = permissions.First(x => x.PermissionType == (int)PermissionTypes.CreateShift).Action; var createShiftPermissions = new List(); - createShiftPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createShiftPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createShiftPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createShiftPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createShiftPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createShiftPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateShiftPermissions = new SelectList(createShiftPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ViewPersonalInfo)) @@ -195,10 +208,10 @@ public async Task Index() model.ViewPersonalInfo = 3; var viewPersonalInfoPermissions = new List(); - viewPersonalInfoPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewPersonalInfoPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewPersonalInfoPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewPersonalInfoPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewPersonalInfoPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewPersonalInfoPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewPersonalInfoPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewPersonalInfoPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewPersonalInfoPermissions = new SelectList(viewPersonalInfoPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.AdjustInventory)) @@ -207,10 +220,10 @@ public async Task Index() model.AdjustInventory = 3; var adjustInventoryPermissions = new List(); - adjustInventoryPermissions.Add(new { Id = 3, Name = "Everyone" }); - adjustInventoryPermissions.Add(new { Id = 0, Name = "Department Admins" }); - adjustInventoryPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - adjustInventoryPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + adjustInventoryPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + adjustInventoryPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + adjustInventoryPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + adjustInventoryPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.AdjustInventoryPermissions = new SelectList(adjustInventoryPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.CanSeePersonnelLocations)) @@ -222,10 +235,10 @@ public async Task Index() model.ViewPersonnelLocation = 3; var viewPersonnelLocationPermissions = new List(); - viewPersonnelLocationPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewPersonnelLocationPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewPersonnelLocationPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewPersonnelLocationPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewPersonnelLocationPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewPersonnelLocationPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewPersonnelLocationPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewPersonnelLocationPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewPersonnelLocationPermissions = new SelectList(viewPersonnelLocationPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.CanSeeUnitLocations)) @@ -237,10 +250,10 @@ public async Task Index() model.ViewUnitLocation = 3; var viewUnitLocationPermissions = new List(); - viewUnitLocationPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewUnitLocationPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewUnitLocationPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewUnitLocationPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewUnitLocationPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewUnitLocationPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewUnitLocationPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewUnitLocationPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewUnitLocationPermissions = new SelectList(viewUnitLocationPermissions, "Id", "Name"); @@ -250,10 +263,10 @@ public async Task Index() model.CreateMessage = 3; var createMessagePermissions = new List(); - createMessagePermissions.Add(new { Id = 3, Name = "Everyone" }); - createMessagePermissions.Add(new { Id = 0, Name = "Department Admins" }); - createMessagePermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createMessagePermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createMessagePermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + createMessagePermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createMessagePermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createMessagePermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateMessagePermissions = new SelectList(createMessagePermissions, "Id", "Name"); @@ -267,10 +280,10 @@ public async Task Index() model.ViewGroupsUsers = 3; var viewGroupUsersPermissions = new List(); - viewGroupUsersPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewGroupUsersPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewGroupUsersPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewGroupUsersPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewGroupUsersPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewGroupUsersPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewGroupUsersPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewGroupUsersPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewGroupUsersPermissions = new SelectList(viewGroupUsersPermissions, "Id", "Name"); @@ -280,10 +293,10 @@ public async Task Index() model.DeleteCall = 3; var deleteCallPermissions = new List(); - deleteCallPermissions.Add(new { Id = 3, Name = "Everyone" }); - deleteCallPermissions.Add(new { Id = 0, Name = "Department Admins" }); - deleteCallPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - deleteCallPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + deleteCallPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + deleteCallPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + deleteCallPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + deleteCallPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.DeleteCallPermissions = new SelectList(deleteCallPermissions, "Id", "Name"); @@ -293,10 +306,10 @@ public async Task Index() model.CloseCall = 3; var closeCallPermissions = new List(); - closeCallPermissions.Add(new { Id = 3, Name = "Everyone" }); - closeCallPermissions.Add(new { Id = 0, Name = "Department Admins" }); - closeCallPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - closeCallPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + closeCallPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + closeCallPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + closeCallPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + closeCallPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CloseCallPermissions = new SelectList(closeCallPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.AddCallData)) @@ -305,10 +318,10 @@ public async Task Index() model.AddCallData = 3; var addCallDataPermissions = new List(); - addCallDataPermissions.Add(new { Id = 3, Name = "Everyone" }); - addCallDataPermissions.Add(new { Id = 0, Name = "Department Admins" }); - addCallDataPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - addCallDataPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + addCallDataPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + addCallDataPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + addCallDataPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + addCallDataPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.AddCallDataPermissions = new SelectList(addCallDataPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ViewGroupUnits)) @@ -320,17 +333,17 @@ public async Task Index() model.ViewGroupsUnits = 3; var viewGroupUnitsPermissions = new List(); - viewGroupUnitsPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewGroupUnitsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewGroupUnitsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewGroupUnitsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewGroupUnitsPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewGroupUnitsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewGroupUnitsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewGroupUnitsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewGrouUnitsPermissions = new SelectList(viewGroupUnitsPermissions, "Id", "Name"); var viewContactsPermissions = new List(); - viewContactsPermissions.Add(new { Id = 3, Name = "Everyone" }); - viewContactsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewContactsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewContactsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + viewContactsPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + viewContactsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewContactsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewContactsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ViewContactsPermissions = new SelectList(viewContactsPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ContactView)) @@ -339,10 +352,10 @@ public async Task Index() model.ViewContacts = 3; var editContactsPermissions = new List(); - editContactsPermissions.Add(new { Id = 3, Name = "Everyone" }); - editContactsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - editContactsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - editContactsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + editContactsPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + editContactsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + editContactsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + editContactsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.EditContactsPermissions = new SelectList(editContactsPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ContactEdit)) @@ -351,10 +364,10 @@ public async Task Index() model.EditContacts = 3; var deleteContactsPermissions = new List(); - deleteContactsPermissions.Add(new { Id = 3, Name = "Everyone" }); - deleteContactsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - deleteContactsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - deleteContactsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + deleteContactsPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + deleteContactsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + deleteContactsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + deleteContactsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.DeleteContactsPermissions = new SelectList(deleteContactsPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ContactDelete)) @@ -366,28 +379,28 @@ public async Task Index() model.CreateWorkflow = permissions.First(x => x.PermissionType == (int)PermissionTypes.CreateWorkflow).Action; var createWorkflowPermissions = new List(); - createWorkflowPermissions.Add(new { Id = 0, Name = "Department Admins" }); - createWorkflowPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - createWorkflowPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + createWorkflowPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + createWorkflowPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + createWorkflowPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CreateWorkflowPermissions = new SelectList(createWorkflowPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ManageWorkflowCredentials)) model.ManageWorkflowCredentials = permissions.First(x => x.PermissionType == (int)PermissionTypes.ManageWorkflowCredentials).Action; var manageWorkflowCredentialsPermissions = new List(); - manageWorkflowCredentialsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - manageWorkflowCredentialsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - manageWorkflowCredentialsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + manageWorkflowCredentialsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + manageWorkflowCredentialsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + manageWorkflowCredentialsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.ManageWorkflowCredentialsPermissions = new SelectList(manageWorkflowCredentialsPermissions, "Id", "Name"); if (permissions.Any(x => x.PermissionType == (int)PermissionTypes.ViewWorkflowRuns)) model.ViewWorkflowRuns = permissions.First(x => x.PermissionType == (int)PermissionTypes.ViewWorkflowRuns).Action; var viewWorkflowRunsPermissions = new List(); - viewWorkflowRunsPermissions.Add(new { Id = 0, Name = "Department Admins" }); - viewWorkflowRunsPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - viewWorkflowRunsPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); - viewWorkflowRunsPermissions.Add(new { Id = 3, Name = "Everyone" }); + viewWorkflowRunsPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + viewWorkflowRunsPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + viewWorkflowRunsPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); + viewWorkflowRunsPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); model.ViewWorkflowRunsPermissions = new SelectList(viewWorkflowRunsPermissions, "Id", "Name"); // 2FA enforcement scope � only managingUser can change this @@ -397,10 +410,10 @@ public async Task Index() model.UseCalendarSync = 3; var useCalendarSyncPermissions = new List(); - useCalendarSyncPermissions.Add(new { Id = 3, Name = "Everyone" }); - useCalendarSyncPermissions.Add(new { Id = 0, Name = "Department Admins" }); - useCalendarSyncPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - useCalendarSyncPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + useCalendarSyncPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + useCalendarSyncPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + useCalendarSyncPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + useCalendarSyncPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.UseCalendarSyncPermissions = new SelectList(useCalendarSyncPermissions, "Id", "Name"); // Dispatch app login: defaults to Everyone so departments that never configure it are unaffected. @@ -410,10 +423,10 @@ public async Task Index() model.DispatchAppLogin = 3; var dispatchAppLoginPermissions = new List(); - dispatchAppLoginPermissions.Add(new { Id = 3, Name = "Everyone" }); - dispatchAppLoginPermissions.Add(new { Id = 0, Name = "Department Admins" }); - dispatchAppLoginPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - dispatchAppLoginPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + dispatchAppLoginPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + dispatchAppLoginPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + dispatchAppLoginPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + dispatchAppLoginPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.DispatchAppLoginPermissions = new SelectList(dispatchAppLoginPermissions, "Id", "Name"); // Commander access: defaults to Everyone so departments that never configure it are unaffected. @@ -423,10 +436,10 @@ public async Task Index() model.CommandAppLogin = 3; var commandAppLoginPermissions = new List(); - commandAppLoginPermissions.Add(new { Id = 3, Name = "Everyone" }); - commandAppLoginPermissions.Add(new { Id = 0, Name = "Department Admins" }); - commandAppLoginPermissions.Add(new { Id = 1, Name = "Department and Group Admins" }); - commandAppLoginPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + commandAppLoginPermissions.Add(new { Id = 3, Name = optionLabels.Everyone }); + commandAppLoginPermissions.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + commandAppLoginPermissions.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + commandAppLoginPermissions.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); model.CommandAppLoginPermissions = new SelectList(commandAppLoginPermissions, "Id", "Name"); // ── Advanced Data Protection (ADP) permissions ───────────────────────────── @@ -441,10 +454,10 @@ SelectList AdpOptions(bool includeEveryone) { var options = new List(); if (includeEveryone) - options.Add(new { Id = 3, Name = "Everyone" }); - options.Add(new { Id = 0, Name = "Department Admins" }); - options.Add(new { Id = 1, Name = "Department and Group Admins" }); - options.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + options.Add(new { Id = 3, Name = optionLabels.Everyone }); + options.Add(new { Id = 0, Name = optionLabels.DepartmentAdmins }); + options.Add(new { Id = 1, Name = optionLabels.DepartmentAndGroupAdmins }); + options.Add(new { Id = 2, Name = optionLabels.DepartmentAdminsAndSelectRoles }); return new SelectList(options, "Id", "Name"); } @@ -482,7 +495,11 @@ SelectList AdpOptions(bool includeEveryone) // Rows come from RecordPermissionCatalog so this screen, ClaimsLogic.AddRecordClaims and the // activation-time row migration share one set of no-row defaults. A missing row preselects that // default, which for the Logs-parity types equals today's CreateLog/DeleteLog fall-through. - model.RecordsPermissions = RecordsPermissionRows.Build(permissions).Concat(RecordsPermissionRows.Build(permissions, ChecklistPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkOrderPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InventoryPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, InvoicingPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, CertificationPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, DeploymentPermissionCatalog.All)).Concat(RecordsPermissionRows.Build(permissions, WorkforcePermissionCatalog.All)).ToList(); + model.RecordsPermissions = new[] + { + RecordPermissionCatalog.All, ChecklistPermissionCatalog.All, WorkOrderPermissionCatalog.All, InventoryPermissionCatalog.All, + InvoicingPermissionCatalog.All, CertificationPermissionCatalog.All, DeploymentPermissionCatalog.All, WorkforcePermissionCatalog.All + }.SelectMany(catalog => RecordsPermissionRows.Build(permissions, catalog, optionLabels)).ToList(); var recordsState = await _recordsCutoverService.GetModuleStateAsync(DepartmentId); model.RecordsFlagEnabled = recordsState != null && recordsState.FlagEnabled; model.RecordsActivated = recordsState != null && recordsState.RecordsUsable; @@ -532,6 +549,8 @@ public async Task GetAuditLogsList() .Where(x => x != null && !String.IsNullOrWhiteSpace(x.UserId)) .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); + var systemActor = _secLocalizer["AuditLogsSystemActor"].Value; + var unknownTime = _secLocalizer["AuditLogsUnknownTime"].Value; foreach (var auditLog in auditLogs) { @@ -541,7 +560,7 @@ public async Task GetAuditLogsList() usersByUserId.TryGetValue(auditLog.UserId ?? String.Empty, out var user); auditJson.Name = personName != null && !String.IsNullOrWhiteSpace(personName.Name) ? personName.Name - : (!String.IsNullOrWhiteSpace(auditLog.UserId) ? auditLog.UserId : "System"); + : (!String.IsNullOrWhiteSpace(auditLog.UserId) ? auditLog.UserId : systemActor); auditJson.Message = auditLog.Message; auditJson.Successful = auditLog.Successful; @@ -551,9 +570,9 @@ public async Task GetAuditLogsList() auditJson.TimestampSort = auditLog.LoggedOn.Value.Ticks / TimeSpan.TicksPerMillisecond; } else - auditJson.Timestamp = "Unknown"; + auditJson.Timestamp = unknownTime; - auditJson.Type = _auditService.GetAuditLogTypeString((AuditLogTypes)auditLog.LogType); + auditJson.Type = GetAuditLogTypeDisplayName((AuditLogTypes)auditLog.LogType); auditJson.SearchTerms = String.Join(" ", new[] { auditJson.Name, @@ -579,6 +598,17 @@ public async Task GetAuditLogsList() return Json(auditLogsJson); } + /// + /// The audit type as shown on the audit log list and detail pages, in the viewer's language. A type with no Security + /// resource yet (a newly added AuditLogTypes value) falls back to the audit service's English name + /// rather than showing the raw resource key. + /// + private string GetAuditLogTypeDisplayName(AuditLogTypes type) + { + var localized = _secLocalizer["AuditLogType" + type]; + return localized.ResourceNotFound ? _auditService.GetAuditLogTypeString(type) : localized.Value; + } + public async Task ViewAudit(int auditLogId) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -596,7 +626,7 @@ public async Task ViewAudit(int auditLogId) AuditLog = auditLog, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId), Type = (AuditLogTypes)auditLog.LogType, - TypeName = _auditService.GetAuditLogTypeString((AuditLogTypes)auditLog.LogType) + TypeName = GetAuditLogTypeDisplayName((AuditLogTypes)auditLog.LogType) }; return View(model); @@ -610,7 +640,7 @@ public async Task ViewAudit(int auditLogId) /// [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task Set2FARequirement(int scope, CancellationToken cancellationToken) { var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -652,9 +682,10 @@ public async Task Set2FARequirement(int scope, CancellationToken // POST + antiforgery: permission changes are state-changing and must never be reachable by a // cross-site top-level GET navigation riding the SameSite=Lax auth cookie. + // Permissions are a security change (passkey plan section 7.6 row 13): the sign-in methods count, Responder approval does not. [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor] + [RequiresRecentTwoFactor(MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SetPermission(int type, int perm, bool? lockToGroup) { if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -710,6 +741,7 @@ public async Task SetPermission(int type, int perm, bool? lockToG } [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SetPermissionData(int type, string data, bool? lockToGroup) { if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -830,6 +862,7 @@ public async Task Sso(CancellationToken cancellationToken) // -- Create SSO config ------------------------------------------------ [HttpGet] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SsoNew(string providerType, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -853,7 +886,8 @@ public async Task SsoNew(string providerType, CancellationToken c ProviderTypes = BuildProviderTypeList(providerType ?? "oidc"), RankList = await BuildRankListAsync(null), AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt(plainToken))}", - ApiBaseUrl = apiBase + ApiBaseUrl = apiBase, + OidcBrokerRedirectUri = OidcBrokerRedirectUri(apiBase) }; return View("SsoEdit", model); @@ -861,6 +895,7 @@ public async Task SsoNew(string providerType, CancellationToken c [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SsoNew(SsoConfigEditView model, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -874,7 +909,7 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok if (!System.Enum.TryParse(model.ProviderType, ignoreCase: true, out var providerType) || !System.Enum.IsDefined(providerType)) { - ModelState.AddModelError("ProviderType", "Invalid provider type."); + ModelState.AddModelError("ProviderType", _secLocalizer["SsoErrorInvalidProviderType"].Value); await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } @@ -889,7 +924,7 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok var existing = await _ssoService.GetSsoConfigForDepartmentAsync(DepartmentId, providerType, cancellationToken); if (existing != null) { - ModelState.AddModelError("", $"An SSO configuration for {model.ProviderType.ToUpperInvariant()} already exists. Use Edit to modify it."); + ModelState.AddModelError("", string.Format(_secLocalizer["SsoErrorConfigAlreadyExists"].Value, model.ProviderType.ToUpperInvariant())); await PopulateSsoEditViewContextAsync(model); return View("SsoEdit", model); } @@ -916,6 +951,7 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok AssertionConsumerServiceUrl = providerType == SsoProviderType.Saml2 && string.IsNullOrWhiteSpace(model.AssertionConsumerServiceUrl) ? $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(encryptedDepartmentToken)}" : model.AssertionConsumerServiceUrl, + IdpSsoUrl = model.IdpSsoUrl, EncryptedIdpCertificate = model.IdpCertificate, EncryptedSigningCertificate = model.SigningCertificate, AttributeMappingJson = model.AttributeMappingJson, @@ -928,13 +964,14 @@ public async Task SsoNew(SsoConfigEditView model, CancellationTok }; await _ssoService.SaveSsoConfigAsync(config, department.Code, cancellationToken); - TempData["SsoSuccess"] = $"{model.ProviderType.ToUpperInvariant()} SSO configuration created successfully."; + TempData["SsoSuccess"] = string.Format(_secLocalizer["SsoConfigCreatedSuccess"].Value, model.ProviderType.ToUpperInvariant()); return RedirectToAction("Sso"); } // -- Edit SSO config -------------------------------------------------- [HttpGet] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SsoEdit(string id, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -959,6 +996,7 @@ public async Task SsoEdit(string id, CancellationToken cancellati MetadataUrl = config.MetadataUrl, EntityId = config.EntityId, AssertionConsumerServiceUrl = config.AssertionConsumerServiceUrl, + IdpSsoUrl = config.IdpSsoUrl, AttributeMappingJson = config.AttributeMappingJson, AllowLocalLogin = config.AllowLocalLogin, AutoProvisionUsers = config.AutoProvisionUsers, @@ -970,7 +1008,8 @@ public async Task SsoEdit(string id, CancellationToken cancellati ProviderTypes = BuildProviderTypeList(((SsoProviderType)config.SsoProviderType).ToString().ToLowerInvariant()), RankList = await BuildRankListAsync(config.DefaultRankId), AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"))}", - ApiBaseUrl = apiBase + ApiBaseUrl = apiBase, + OidcBrokerRedirectUri = OidcBrokerRedirectUri(apiBase) }; return View("SsoEdit", model); @@ -978,6 +1017,7 @@ public async Task SsoEdit(string id, CancellationToken cancellati [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SsoEdit(SsoConfigEditView model, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1013,6 +1053,7 @@ public async Task SsoEdit(SsoConfigEditView model, CancellationTo config.MetadataUrl = model.MetadataUrl ?? config.MetadataUrl; config.EntityId = model.EntityId ?? config.EntityId; config.AssertionConsumerServiceUrl = model.AssertionConsumerServiceUrl ?? config.AssertionConsumerServiceUrl; + config.IdpSsoUrl = model.IdpSsoUrl ?? config.IdpSsoUrl; config.AttributeMappingJson = model.AttributeMappingJson ?? config.AttributeMappingJson; config.AllowLocalLogin = model.AllowLocalLogin; config.AutoProvisionUsers = model.AutoProvisionUsers; @@ -1026,7 +1067,7 @@ public async Task SsoEdit(SsoConfigEditView model, CancellationTo config.EncryptedSigningCertificate = !string.IsNullOrWhiteSpace(model.SigningCertificate) ? model.SigningCertificate : null; await _ssoService.SaveSsoConfigAsync(config, department.Code, cancellationToken); - TempData["SsoSuccess"] = "SSO configuration updated successfully."; + TempData["SsoSuccess"] = _secLocalizer["SsoConfigUpdatedSuccess"].Value; return RedirectToAction("Sso"); } @@ -1034,6 +1075,7 @@ public async Task SsoEdit(SsoConfigEditView model, CancellationTo [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SsoDelete(string id, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1047,7 +1089,7 @@ public async Task SsoDelete(string id, CancellationToken cancella var providerType = (SsoProviderType)config.SsoProviderType; await _ssoService.DeleteSsoConfigAsync(DepartmentId, providerType, cancellationToken); - TempData["SsoSuccess"] = $"{providerType.ToString().ToUpperInvariant()} SSO configuration deleted."; + TempData["SsoSuccess"] = string.Format(_secLocalizer["SsoConfigDeletedSuccess"].Value, providerType.ToString().ToUpperInvariant()); return RedirectToAction("Sso"); } @@ -1055,7 +1097,7 @@ public async Task SsoDelete(string id, CancellationToken cancella [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task GenerateScimTokenFromSso(string id, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1170,6 +1212,7 @@ public async Task ScimSetup(string id, CancellationToken cancella [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task RotateScimToken(string id, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1217,6 +1260,7 @@ public async Task RotateScimToken(string id, CancellationToken ca // -- Security policy -------------------------------------------------- [HttpGet] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SecurityPolicy(CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1229,12 +1273,7 @@ public async Task SecurityPolicy(CancellationToken cancellationTo var model = new SecurityPolicyEditView { HasActiveSsoConfig = hasActiveConfig, - DataClassificationLevels = new SelectList(new[] - { - new { Id = 0, Name = "Unclassified" }, - new { Id = 1, Name = "CUI - Controlled Unclassified Information" }, - new { Id = 2, Name = "Confidential" } - }, "Id", "Name", policy?.DataClassificationLevel ?? 0) + DataClassificationLevels = BuildDataClassificationList(policy?.DataClassificationLevel ?? 0) }; if (policy != null) @@ -1254,11 +1293,15 @@ public async Task SecurityPolicy(CancellationToken cancellationTo model.MinPasswordLength = 8; } + CopyMethodSwitches(policy ?? new DepartmentSecurityPolicy(), model); + await DescribeMethodSwitchesAsync(model); + return View(model); } [HttpPost] [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] public async Task SecurityPolicy(SecurityPolicyEditView model, CancellationToken cancellationToken) { if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -1267,12 +1310,8 @@ public async Task SecurityPolicy(SecurityPolicyEditView model, Ca var configs = await _ssoService.GetSsoConfigsForDepartmentAsync(DepartmentId, cancellationToken); var hasActiveConfig = configs?.Any(c => c.IsEnabled) ?? false; model.HasActiveSsoConfig = hasActiveConfig; - model.DataClassificationLevels = new SelectList(new[] - { - new { Id = 0, Name = "Unclassified" }, - new { Id = 1, Name = "CUI - Controlled Unclassified Information" }, - new { Id = 2, Name = "Confidential" } - }, "Id", "Name", model.DataClassificationLevel); + await DescribeMethodSwitchesAsync(model); + model.DataClassificationLevels = BuildDataClassificationList(model.DataClassificationLevel); if (!ModelState.IsValid) return View(model); @@ -1296,6 +1335,7 @@ public async Task SecurityPolicy(SecurityPolicyEditView model, Ca DepartmentId = DepartmentId, CreatedOn = DateTime.UtcNow }; + var before = DepartmentSecurityPolicyDecisions.SnapshotMfaRules(policy); policy.RequireMfa = model.RequireMfa; policy.RequireSso = model.RequireSso; @@ -1309,18 +1349,220 @@ public async Task SecurityPolicy(SecurityPolicyEditView model, Ca policy.RequirePasswordComplexity = true; policy.DataClassificationLevel = model.DataClassificationLevel; - await _ssoService.SaveSecurityPolicyAsync(policy, cancellationToken); + // Which second factors are accepted is the managing member's decision (passkey plan section 10.1). Other + // administrators see the switches read-only, and whatever their form posts for them is ignored: a disabled + // checkbox still posts its hidden "false", which must never switch a method off. + if (model.CanChangeMethodSwitches) + { + policy.AllowPasskeysForLoginMfa = model.AllowPasskeysForLoginMfa; + policy.AllowPasskeysForAdp = model.AllowPasskeysForAdp; + policy.AllowFederatedMfaForLoginMfa = model.AllowFederatedMfaForLoginMfa; + policy.AllowFederatedMfaForAdp = model.AllowFederatedMfaForAdp; + policy.AllowResponderApproval = model.AllowResponderApproval; + policy.AcceptRecentLoginMfaForAdp = model.AcceptRecentLoginMfaForAdp; + policy.AcceptRecentUnlockMfaForAdp = model.AcceptRecentUnlockMfaForAdp; + + // The shared-device policy is the managing member's too (plan section 10.5). Stricter values reach running + // sessions at their next request; a new app requirement needs the deployment to offer shared mode. + policy.SharedIdleLockMinutes = model.SharedIdleLockMinutes; + policy.SharedShiftHours = model.SharedShiftHours; + policy.SharedModeRequiredApps = model.SharedModeRequiredApps; + if (policy.SharedIdleLockMinutes < 1 || policy.SharedIdleLockMinutes > SharedSessionRules.MaxIdleLockMinutes) + { + ModelState.AddModelError("SharedIdleLockMinutes", string.Format(_secLocalizer["SecurityPolicySharedIdleOutOfRange"].Value, + SharedSessionRules.MaxIdleLockMinutes)); + return View(model); + } + if (policy.SharedShiftHours < 1 || policy.SharedShiftHours > SharedSessionRules.MaxShiftHours) + { + ModelState.AddModelError("SharedShiftHours", string.Format(_secLocalizer["SecurityPolicySharedShiftOutOfRange"].Value, + SharedSessionRules.MaxShiftHours)); + return View(model); + } + if (!_passkeyGates.SharedDeviceModeEnabled && DepartmentSecurityPolicyDecisions.AddsSharedRequirement(before, policy)) + { + ModelState.AddModelError(string.Empty, _secLocalizer["SecurityPolicySharedRequirementUnavailable"].Value); + return View(model); + } + } + + // Turning provider step-up on needs a mapping that passed its test, and cannot be authorized by provider step-up + // itself: the 5-minute proof above must be a Resgrid factor (plan section 7.8). + if (DepartmentSecurityPolicyDecisions.EnablesFederatedMfa(before, policy)) + { + if (await _ssoService.GetTestedFederatedMfaConfigAsync(DepartmentId, cancellationToken) == null) + { + ModelState.AddModelError(string.Empty, _secLocalizer["SecurityPolicyFederatedMappingUntested"].Value); + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (await StepUpEvidence.GetLatestSecondFactorUtcAsync(_mfaEvidence, user, HttpContext, _mfaPolicy, DepartmentId, + Resgrid.Model.Security.MfaMethodScope.SecurityChange, cancellationToken, excludeFederated: true) == null) + { + ModelState.AddModelError(string.Empty, _secLocalizer["SecurityPolicyFederatedNeedsResgridMfa"].Value); + return View(model); + } + } + + await _ssoService.SaveSecurityPolicyAsync(policy, UserId, cancellationToken); TempData["PolicySuccess"] = _secLocalizer["SecurityPolicySaveSuccess"].Value; return RedirectToAction("SecurityPolicy"); } + // -- Provider step-up mapping (passkey plan section 7.8) -------------- + + [HttpGet] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] + public async Task FederatedMfa(CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return RedirectToAction("Index"); + + var config = await ActiveSsoConfigAsync(cancellationToken); + var model = new FederatedMfaEditView(); + model.CopyFrom(Resgrid.Model.Security.FederatedMfaMapping.Parse(config?.FederatedMfaMappingJson)); + await DescribeFederatedMfaAsync(model, config); + + return View(model); + } + + /// + /// Saves, or with "remove" removes, the active SSO configuration's provider step-up mapping. + /// Managing member only. Every change advances the mapping version, so the new mapping counts only after its own test, + /// and provider step-up cannot approve a change to what counts as provider step-up. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true, VerificationWindowMinutes = 5, MethodScope = Resgrid.Model.Security.MfaMethodScope.SecurityChange)] + public async Task FederatedMfa(FederatedMfaEditView model, string command, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return RedirectToAction("Index"); + + model ??= new FederatedMfaEditView(); + var config = await ActiveSsoConfigAsync(cancellationToken); + await DescribeFederatedMfaAsync(model, config); + if (config == null) + return View(model); + + if (!model.CanChange) + { + ModelState.AddModelError(string.Empty, _secLocalizer["SecurityPolicyMfaMethodsManagingMemberOnly"].Value); + return View(model); + } + + var user = await _userManager.GetUserAsync(User); + if (await StepUpEvidence.GetLatestSecondFactorUtcAsync(_mfaEvidence, user, HttpContext, _mfaPolicy, DepartmentId, + Resgrid.Model.Security.MfaMethodScope.SecurityChange, cancellationToken, excludeFederated: true) == null) + { + ModelState.AddModelError(string.Empty, _secLocalizer["FederatedMfaNeedsResgridMfa"].Value); + return View(model); + } + + var removing = string.Equals(command, "remove", StringComparison.Ordinal); + string mappingJson = null; + if (!removing) + { + var mapping = model.ToMapping(); + var problem = Resgrid.Model.Security.FederatedMfaMapping.Validate(mapping, (SsoProviderType)config.SsoProviderType); + if (problem != null) + { + ModelState.AddModelError(string.Empty, string.Format(_secLocalizer["FederatedMfaInvalid"].Value, problem)); + return View(model); + } + mappingJson = mapping.Serialize(); + } + + if (!string.Equals(config.FederatedMfaMappingJson ?? string.Empty, mappingJson ?? string.Empty, StringComparison.Ordinal)) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + config.FederatedMfaMappingJson = mappingJson; + config.UpdatedByUserId = UserId; + var saved = await _ssoService.SaveSsoConfigAsync(config, department.Code, cancellationToken); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.FederatedMfaMappingChanged, + UserId = UserId, + Username = UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = removing + ? $"Provider step-up mapping for SSO configuration {config.DepartmentSsoConfigId} removed (now version {saved?.FederatedMfaMappingVersion})." + : $"Provider step-up mapping for SSO configuration {config.DepartmentSsoConfigId} saved as version {saved?.FederatedMfaMappingVersion}; " + + "it counts once it passes its test." + }, cancellationToken); + } + + TempData["FederatedMfaSuccess"] = _secLocalizer[removing ? "FederatedMfaRemoved" : "FederatedMfaSaved"].Value; + return RedirectToAction("FederatedMfa"); + } + // -- Private helpers -------------------------------------------------- - private static SelectList BuildProviderTypeList(string selected) => + private async Task ActiveSsoConfigAsync(CancellationToken cancellationToken) => + (await _ssoService.GetSsoConfigsForDepartmentAsync(DepartmentId, cancellationToken))?.FirstOrDefault(c => c.IsEnabled); + + /// The stored mapping's state and who may change it, always from the server. + private async Task DescribeFederatedMfaAsync(FederatedMfaEditView model, DepartmentSsoConfig config) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.CanChange = department != null && department.ManagingUserId == UserId; + model.ProviderStepUpAvailable = _passkeyGates.ProviderStepUpEnabled; + model.HasActiveSsoConfig = config != null; + model.IsOidc = config?.SsoProviderType == (int)SsoProviderType.Oidc; + model.HasMapping = !string.IsNullOrWhiteSpace(config?.FederatedMfaMappingJson); + model.MappingVersion = config?.FederatedMfaMappingVersion ?? 0; + model.Effective = Resgrid.Model.Security.FederatedMfaMapping.IsTested(config); + model.TestedOnUtc = model.Effective ? config.FederatedMfaTestedOnUtc : null; + } + + /// Who may change the method switches, and which methods this deployment offers yet (never from the client). + private async Task DescribeMethodSwitchesAsync(SecurityPolicyEditView model) + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.CanChangeMethodSwitches = department != null && department.ManagingUserId == UserId; + model.PasskeysAvailable = _passkeyGates.LoginAcceptanceEnabled || _passkeyGates.AdpAcceptanceEnabled; + model.ResponderApprovalAvailable = _passkeyGates.ResponderApprovalEnabled; + model.ProviderStepUpAvailable = _passkeyGates.ProviderStepUpEnabled; + model.SharedDeviceModeAvailable = _passkeyGates.SharedDeviceModeEnabled; + model.MaxSharedIdleLockMinutes = SharedSessionRules.MaxIdleLockMinutes; + model.MaxSharedShiftHours = SharedSessionRules.MaxShiftHours; + } + + private static void CopyMethodSwitches(DepartmentSecurityPolicy policy, SecurityPolicyEditView model) + { + model.AllowPasskeysForLoginMfa = policy.AllowPasskeysForLoginMfa; + model.AllowPasskeysForAdp = policy.AllowPasskeysForAdp; + model.AllowFederatedMfaForLoginMfa = policy.AllowFederatedMfaForLoginMfa; + model.AllowFederatedMfaForAdp = policy.AllowFederatedMfaForAdp; + model.AllowResponderApproval = policy.AllowResponderApproval; + model.AcceptRecentLoginMfaForAdp = policy.AcceptRecentLoginMfaForAdp; + model.AcceptRecentUnlockMfaForAdp = policy.AcceptRecentUnlockMfaForAdp; + model.SharedIdleLockMinutes = policy.SharedIdleLockMinutes; + model.SharedShiftHours = policy.SharedShiftHours; + var required = (SharedModeApps)policy.SharedModeRequiredApps; + model.RequireSharedModeForUnit = required.HasFlag(SharedModeApps.Unit); + model.RequireSharedModeForCommand = required.HasFlag(SharedModeApps.Command); + model.RequireSharedModeForDispatch = required.HasFlag(SharedModeApps.Dispatch); + } + + private SelectList BuildDataClassificationList(int selected) => new SelectList(new[] { - new { Id = "oidc", Name = "OIDC (OpenID Connect) � Microsoft Entra, Okta, Google, Auth0" }, - new { Id = "saml2", Name = "SAML 2.0 � Most enterprise / government IdPs" } + new { Id = 0, Name = _secLocalizer["SecurityPolicyDataClassUnclassified"].Value }, + new { Id = 1, Name = _secLocalizer["SecurityPolicyDataClassCui"].Value }, + new { Id = 2, Name = _secLocalizer["SecurityPolicyDataClassConfidential"].Value } + }, "Id", "Name", selected); + + private SelectList BuildProviderTypeList(string selected) => + new SelectList(new[] + { + new { Id = "oidc", Name = _secLocalizer["SsoEditProviderTypeOidcOption"].Value }, + new { Id = "saml2", Name = _secLocalizer["SsoEditProviderTypeSamlOption"].Value } }, "Id", "Name", selected); private void ValidateSsoProviderConfiguration(SsoConfigEditView model, SsoProviderType providerType, bool hasStoredIdpCertificate) @@ -1328,18 +1570,25 @@ private void ValidateSsoProviderConfiguration(SsoConfigEditView model, SsoProvid if (providerType == SsoProviderType.Oidc) { if (string.IsNullOrWhiteSpace(model.ClientId)) - ModelState.AddModelError("ClientId", "OIDC client ID is required."); + ModelState.AddModelError("ClientId", _secLocalizer["SsoErrorOidcClientIdRequired"].Value); if (!Uri.TryCreate(model.Authority, UriKind.Absolute, out var authority) || authority.Scheme != Uri.UriSchemeHttps) - ModelState.AddModelError("Authority", "OIDC authority must be a valid HTTPS URL."); + ModelState.AddModelError("Authority", _secLocalizer["SsoErrorOidcAuthorityInvalid"].Value); return; } if (!hasStoredIdpCertificate && string.IsNullOrWhiteSpace(model.IdpCertificate)) - ModelState.AddModelError("IdpCertificate", "An IdP signing certificate is required to validate SAML assertions."); + ModelState.AddModelError("IdpCertificate", _secLocalizer["SsoErrorSamlCertificateRequired"].Value); + + if (!string.IsNullOrWhiteSpace(model.IdpSsoUrl) && + (!Uri.TryCreate(model.IdpSsoUrl, UriKind.Absolute, out var idpSsoUrl) || idpSsoUrl.Scheme != Uri.UriSchemeHttps)) + ModelState.AddModelError("IdpSsoUrl", _secLocalizer["SsoErrorIdpSsoUrlInvalid"].Value); } + /// The redirect URI a department registers with its OIDC IdP for brokered sign-in (plan section 7.7.2 item 7). + private static string OidcBrokerRedirectUri(string apiBase) => $"{apiBase?.TrimEnd('/')}{Config.SsoConfig.OidcCallbackPath}"; + private async Task PopulateSsoEditViewContextAsync(SsoConfigEditView model) { var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); @@ -1347,6 +1596,7 @@ private async Task PopulateSsoEditViewContextAsync(SsoConfigEditView model) model.ProviderTypes = BuildProviderTypeList(model.ProviderType); model.RankList = await BuildRankListAsync(model.DefaultRankId); model.ApiBaseUrl = apiBase; + model.OidcBrokerRedirectUri = OidcBrokerRedirectUri(apiBase); model.AcsUrl = $"{apiBase}{Config.SsoConfig.SamlAcsPath}?departmentToken={Uri.EscapeDataString(_encryptionService.Encrypt($"{department.DepartmentId}:{department.Code}"))}"; } @@ -1355,7 +1605,7 @@ private async Task BuildRankListAsync(int? selectedRankId) // Ranks are not currently implemented as a standalone service � return empty with placeholder await Task.CompletedTask; return new SelectList( - new[] { new { Id = (int?)null, Name = "(No default rank)" } }, + new[] { new { Id = (int?)null, Name = _secLocalizer["SsoEditDefaultRankNone"].Value } }, "Id", "Name", selectedRankId); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs b/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs index cd36e9931..0df7d7f4c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/TwoFactorController.cs @@ -12,8 +12,13 @@ using QRCoder; using Resgrid.Config; using Resgrid.Framework; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Security; using Resgrid.Model.Services; +using Resgrid.Repositories.DataRepository.Stores; using Resgrid.Web.Areas.User.Models.TwoFactor; using Resgrid.Web.Attributes; using Resgrid.Web.Helpers; @@ -23,6 +28,9 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [Authorize] + // Authentication and session flows stay available during a department operation lock (ADP plan section 20.2): signing in + // and out, locking and unlocking a shared session, and verifying a second factor touch no department data. + [Resgrid.Web.Filters.AllowDuringDepartmentLock] public class TwoFactorController : SecureBaseController { private readonly UserManager _userManager; @@ -30,25 +38,77 @@ public class TwoFactorController : SecureBaseController private readonly ISystemAuditsService _systemAuditsService; private readonly UrlEncoder _urlEncoder; private readonly IStringLocalizer _localizer; + private readonly IUserStore _userStore; + private readonly IUserMfaStateRepository _mfaStateRepository; + private readonly IUserSessionService _userSessionService; + private readonly IMfaEvidenceService _mfaEvidenceService; + + // A new authenticator key is held here until a code from it verifies; the active key is never redisplayed + // (passkey plan section 6.2). + private const string StagedKeyLoginProvider = StagedAuthenticatorKey.LoginProvider; + private const string StagedKeyTokenName = StagedAuthenticatorKey.TokenName; + + // Replacement authority: a TOTP step-up this recent, or a recovery-code sign-in this recent. + private const int ReplaceStepUpWindowMinutes = 5; + private const int ReplaceRecoverySessionWindowMinutes = 15; public TwoFactorController( UserManager userManager, SignInManager signInManager, ISystemAuditsService systemAuditsService, UrlEncoder urlEncoder, - IStringLocalizer localizer) + IStringLocalizer localizer, + IUserStore userStore, + IUserMfaStateRepository mfaStateRepository, + IUserSessionService userSessionService, + IMfaEvidenceService mfaEvidenceService, + IMfaPolicyService mfaPolicyService, + IUserPasskeyRepository passkeyRepository, + ISecurityNoticeService securityNotices, + IMfaActivityService mfaActivity, + IPasskeyService passkeys, + IMfaApprovalService approvals, + ISsoBrokerService ssoBroker, + ISsoReturnTargetRegistry ssoReturnTargets, + IDepartmentSsoService departmentSso, + IDepartmentsService departments) { + _departments = departments; + _passkeys = passkeys; + _approvals = approvals; + _ssoBroker = ssoBroker; + _ssoReturnTargets = ssoReturnTargets; + _departmentSso = departmentSso; + _mfaActivity = mfaActivity; + _securityNotices = securityNotices; + _mfaPolicyService = mfaPolicyService; + _passkeyRepository = passkeyRepository; _userManager = userManager; _signInManager = signInManager; _systemAuditsService = systemAuditsService; _urlEncoder = urlEncoder; _localizer = localizer; + _userStore = userStore; + _mfaStateRepository = mfaStateRepository; + _userSessionService = userSessionService; + _mfaEvidenceService = mfaEvidenceService; } + private readonly IMfaPolicyService _mfaPolicyService; + private readonly IUserPasskeyRepository _passkeyRepository; + private readonly ISecurityNoticeService _securityNotices; + private readonly IMfaActivityService _mfaActivity; + private readonly IPasskeyService _passkeys; + private readonly IMfaApprovalService _approvals; + private readonly ISsoBrokerService _ssoBroker; + private readonly ISsoReturnTargetRegistry _ssoReturnTargets; + private readonly IDepartmentSsoService _departmentSso; + private readonly IDepartmentsService _departments; + // ── Index ───────────────────────────────────────────────────────────────── [HttpGet] - public async Task Index() + public async Task Index(string passkeyStatus = null, CancellationToken cancellationToken = default) { var user = await _userManager.GetUserAsync(User); if (user == null) return NotFound(); @@ -60,12 +120,34 @@ public async Task Index() HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null, Is2FAEnabled = await _userManager.GetTwoFactorEnabledAsync(user), RecoveryCodesLeft = codesLeft, - RecoveryCodeWarning = codesLeft <= TwoFactorConfig.RecoveryCodeWarningThreshold + RecoveryCodeWarning = codesLeft <= TwoFactorConfig.RecoveryCodeWarningThreshold, + WebPasskeyRegistrationAvailable = _passkeys.IsRegistrationAvailable(UserSessionClientApplication.Web), + PasskeyStatus = passkeyStatus is "added" or "renamed" or "removed" ? passkeyStatus : null, + Passkeys = (await _passkeys.GetActiveForUserAsync(user.Id, cancellationToken) ?? Array.Empty()) + .OrderBy(p => p.ClientApplication).ThenBy(p => p.CreatedOnUtc) + .Select(p => new PasskeyRowView + { + Id = p.UserPasskeyId, + DisplayName = p.DisplayName, + AppLabelKey = AppLabelKey((UserSessionClientApplication)p.ClientApplication), + CreatedOn = DateTime.SpecifyKind(p.CreatedOnUtc, DateTimeKind.Utc), + LastUsedOn = p.LastUsedOnUtc == null ? null : DateTime.SpecifyKind(p.LastUsedOnUtc.Value, DateTimeKind.Utc), + CreatedOnSharedInstallation = p.RegisteredInSharedMode + }).ToList() }; return View(model); } + private static string AppLabelKey(UserSessionClientApplication client) => client switch + { + UserSessionClientApplication.Responder => "PasskeyAppResponder", + UserSessionClientApplication.Unit => "PasskeyAppUnit", + UserSessionClientApplication.Dispatch => "PasskeyAppDispatch", + UserSessionClientApplication.Command => "PasskeyAppCommand", + _ => "PasskeyAppWeb" + }; + // ── Enable 2FA ──────────────────────────────────────────────────────────── [HttpGet] @@ -74,25 +156,15 @@ public async Task Enable2FA() var user = await _userManager.GetUserAsync(User); if (user == null) return NotFound(); - var key = await _userManager.GetAuthenticatorKeyAsync(user); - if (string.IsNullOrEmpty(key)) - { - await _userManager.ResetAuthenticatorKeyAsync(user); - key = await _userManager.GetAuthenticatorKeyAsync(user); - } - - var formattedKey = FormatKey(key); - var email = await _userManager.GetEmailAsync(user); - var uri = GenerateQrCodeUri(email, key); + // An enrolled authenticator's secret is never shown again; moving to a new app is ReplaceAuthenticator. + if (await _userManager.GetTwoFactorEnabledAsync(user)) + return RedirectToAction(nameof(Index)); - var model = new EnableAuthenticatorViewModel - { - SharedKey = formattedKey, - AuthenticatorUri = uri, - QrCodeDataUrl = GenerateQrCodeDataUrl(uri) - }; + // Setting up a factor needs a recent password (or SSO) verification for this session (plan section 6.2). + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) + return RedirectToReauthenticate(); - return View(model); + return View(await BuildAuthenticatorModelAsync(user, await StageNewAuthenticatorKeyAsync(user), isReplacement: false)); } [HttpPost] @@ -102,30 +174,29 @@ public async Task Enable2FA(EnableAuthenticatorViewModel model, C var user = await _userManager.GetUserAsync(User); if (user == null) return NotFound(); - if (!ModelState.IsValid) - { - // Re-populate QR data - var key2 = await _userManager.GetAuthenticatorKeyAsync(user); - model.SharedKey = FormatKey(key2); - model.AuthenticatorUri = GenerateQrCodeUri(await _userManager.GetEmailAsync(user), key2); - model.QrCodeDataUrl = GenerateQrCodeDataUrl(model.AuthenticatorUri); - return View(model); - } + if (await _userManager.GetTwoFactorEnabledAsync(user)) + return RedirectToAction(nameof(Index)); - var verificationCode = model.Code.Replace(" ", string.Empty).Replace("-", string.Empty); - var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, - _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); + // The change started inside the reauthentication window; it must also finish inside the operation window. + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorOperationWindowMinutes)) + return RedirectToReauthenticate(Url.Action(nameof(Enable2FA))); - if (!isValid) + var stagedKey = await GetStagedAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(stagedKey)) + return RedirectToAction(nameof(Enable2FA)); + + if (!ModelState.IsValid) + return View(await BuildAuthenticatorModelAsync(user, stagedKey, isReplacement: false)); + + // The staged key is verified directly and its time step consumed, so the same code cannot be replayed. + if (!await ResgridAuthenticatorTokenProvider.ValidateAndConsumeAsync(_mfaStateRepository, user.Id, stagedKey, + model.Code, DateTime.UtcNow, cancellationToken)) { ModelState.AddModelError(nameof(model.Code), _localizer["InvalidCodeError"]); - var key2 = await _userManager.GetAuthenticatorKeyAsync(user); - model.SharedKey = FormatKey(key2); - model.AuthenticatorUri = GenerateQrCodeUri(await _userManager.GetEmailAsync(user), key2); - model.QrCodeDataUrl = GenerateQrCodeDataUrl(model.AuthenticatorUri); - return View(model); + return View(await BuildAuthenticatorModelAsync(user, stagedKey, isReplacement: false)); } + await PromoteStagedAuthenticatorKeyAsync(user, stagedKey, cancellationToken); await _userManager.SetTwoFactorEnabledAsync(user, true); await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit @@ -139,6 +210,7 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit ServerName = Environment.MachineName, Data = $"2FA enabled via web. {Request.Headers["User-Agent"]}" }, cancellationToken); + await NoticeAsync(user, SecurityNoticeKind.TotpEnabled, cancellationToken); var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, TwoFactorConfig.DefaultRecoveryCodeCount); @@ -148,6 +220,92 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit return RedirectToAction(nameof(ShowRecoveryCodes)); } + // ── Replace Authenticator ───────────────────────────────────────────────── + // Moves an enrolled account to a new authenticator (passkey plan sections 6.2, 7.5 rule 7). Allowed after a fresh + // TOTP step-up, or shortly after a recovery-code sign-in (the lost-authenticator journey). The new key is staged + // and verified before it replaces the old one; success rotates recovery codes, advances the authentication + // generation and signs every session out. + + [HttpGet] + public async Task ReplaceAuthenticator(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return NotFound(); + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return RedirectToAction(nameof(Enable2FA)); + + // Replacement needs a fresh first factor AND an existing factor (or recovery authority). + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) + return RedirectToReauthenticate(); + + if (!await HasReplacementAuthorityAsync(user, cancellationToken)) + return RedirectToStepUp(); + + return View(nameof(Enable2FA), await BuildAuthenticatorModelAsync(user, await StageNewAuthenticatorKeyAsync(user), isReplacement: true)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ReplaceAuthenticator(EnableAuthenticatorViewModel model, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return NotFound(); + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return RedirectToAction(nameof(Enable2FA)); + + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorOperationWindowMinutes)) + return RedirectToReauthenticate(Url.Action(nameof(ReplaceAuthenticator))); + + if (!await HasReplacementAuthorityAsync(user, cancellationToken)) + return RedirectToStepUp(); + + var stagedKey = await GetStagedAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(stagedKey)) + return RedirectToAction(nameof(ReplaceAuthenticator)); + + if (!ModelState.IsValid) + return View(nameof(Enable2FA), await BuildAuthenticatorModelAsync(user, stagedKey, isReplacement: true)); + + var now = DateTime.UtcNow; + if (!await ResgridAuthenticatorTokenProvider.ValidateAndConsumeAsync(_mfaStateRepository, user.Id, stagedKey, + model.Code, now, cancellationToken)) + { + ModelState.AddModelError(nameof(model.Code), _localizer["InvalidCodeError"]); + return View(nameof(Enable2FA), await BuildAuthenticatorModelAsync(user, stagedKey, isReplacement: true)); + } + + await PromoteStagedAuthenticatorKeyAsync(user, stagedKey, cancellationToken); + + // The old seed, remembered browsers (security stamp) and every session derived from the old factor end here. + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; + await _userManager.UpdateSecurityStampAsync(user); + var recoveryCodes = (await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, TwoFactorConfig.DefaultRecoveryCodeCount)).ToArray(); + await _userSessionService.RevokeAllAfterCredentialChangeAsync(user.Id, user.Id, + UserSessionRevocationReason.MfaChanged, now, cancellationToken); + await _mfaEvidenceService.RevokeForUserAsync(user.Id, cancellationToken); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.TwoFactorAuthenticatorReplaced, + UserId = user.Id, + Username = user.UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = $"Authenticator replaced via web; all sessions revoked. {Request.Headers["User-Agent"]}" + }, cancellationToken); + await NoticeAsync(user, SecurityNoticeKind.TotpReplaced, cancellationToken); + + // This request is still authenticated, so the new codes render once before the cookie is gone. + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return View(nameof(ShowRecoveryCodes), new ShowRecoveryCodesViewModel { RecoveryCodes = recoveryCodes, SignInAgainRequired = true }); + } + // ── Recovery Codes ──────────────────────────────────────────────────────── [HttpGet] @@ -160,8 +318,10 @@ public IActionResult ShowRecoveryCodes() return View(new ShowRecoveryCodesViewModel { RecoveryCodes = codes }); } + // Recovery codes are account factor management (passkey plan section 7.6 row 14): TOTP or this app's passkey only, + // never Responder approval or provider step-up, whatever the department accepts for sign-in. [HttpGet] - [RequiresRecentTwoFactor] + [RequiresRecentTwoFactor(MethodScope = MfaMethodScope.Account)] public async Task ViewRecoveryCodes() { var user = await _userManager.GetUserAsync(User); @@ -176,7 +336,7 @@ public async Task ViewRecoveryCodes() [HttpPost] [ValidateAntiForgeryToken] - [RequiresRecentTwoFactor] + [RequiresRecentTwoFactor(MethodScope = MfaMethodScope.Account)] public async Task RegenerateRecoveryCodes() { var user = await _userManager.GetUserAsync(User); @@ -186,6 +346,7 @@ public async Task RegenerateRecoveryCodes() return RedirectToAction(nameof(Index)); var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, TwoFactorConfig.DefaultRecoveryCodeCount); + await NoticeAsync(user, SecurityNoticeKind.RecoveryCodesRegenerated, HttpContext.RequestAborted); TempData["RecoveryCodes"] = recoveryCodes.ToArray(); TempData["StatusMessage"] = "Your recovery codes have been regenerated."; @@ -195,7 +356,16 @@ public async Task RegenerateRecoveryCodes() // ── Disable 2FA ─────────────────────────────────────────────────────────── [HttpGet] - public IActionResult Disable2FA() => View(new Disable2FAViewModel()); + public async Task Disable2FA() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return NotFound(); + + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorReauthWindowMinutes)) + return RedirectToReauthenticate(); + + return View(new Disable2FAViewModel { BlockedByPasskeys = await _passkeyRepository.CountActiveForUserAsync(user.Id) > 0 }); + } [HttpPost] [ValidateAntiForgeryToken] @@ -204,6 +374,18 @@ public async Task Disable2FA(Disable2FAViewModel model, Cancellat var user = await _userManager.GetUserAsync(User); if (user == null) return NotFound(); + if (!await HasFreshFirstFactorAsync(user, TwoFactorConfig.FirstFactorOperationWindowMinutes)) + return RedirectToReauthenticate(Url.Action(nameof(Disable2FA))); + + // TOTP is the fallback every passkey relies on in this release, so it cannot be turned off while any passkey + // exists (plan section 7.5 rule 7). Remove the passkeys first. + model.BlockedByPasskeys = await _passkeyRepository.CountActiveForUserAsync(user.Id, cancellationToken) > 0; + if (model.BlockedByPasskeys) + { + ModelState.AddModelError(string.Empty, _localizer["DisableBlockedByPasskeys"]); + return View(model); + } + if (!ModelState.IsValid) return View(model); var verificationCode = model.Code.Replace(" ", string.Empty).Replace("-", string.Empty); @@ -216,8 +398,17 @@ public async Task Disable2FA(Disable2FAViewModel model, Cancellat return View(model); } + // Turning MFA off advances the authentication generation, discards the seed and recovery codes, forgets + // remembered browsers and ends every session established under the old factor (plan section 7.5 rule 7). + var now = DateTime.UtcNow; + user.AuthenticationGeneration++; + user.CredentialsValidAfterUtc = now; + user.AuthenticationStateChangedOn = now; await _userManager.SetTwoFactorEnabledAsync(user, false); await _userManager.ResetAuthenticatorKeyAsync(user); + await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 0); + await _userManager.RemoveAuthenticationTokenAsync(user, StagedKeyLoginProvider, StagedKeyTokenName); + await _mfaEvidenceService.RevokeForUserAsync(user.Id, cancellationToken); await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit { @@ -228,46 +419,356 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit Successful = true, IpAddress = IpAddressHelper.GetRequestIP(Request, true), ServerName = Environment.MachineName, - Data = $"2FA disabled via web. {Request.Headers["User-Agent"]}" + Data = $"2FA disabled via web; all sessions revoked. {Request.Headers["User-Agent"]}" }, cancellationToken); + await NoticeAsync(user, SecurityNoticeKind.TotpDisabled, cancellationToken); - TempData["StatusMessage"] = "Two-factor authentication has been disabled."; - return RedirectToAction(nameof(Index)); + await _signInManager.ForgetTwoFactorClientAsync(); + await _userSessionService.RevokeAllAfterCredentialChangeAsync(user.Id, user.Id, + UserSessionRevocationReason.MfaChanged, now, cancellationToken); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return RedirectToAction("LogOn", "Account", new { area = "", reason = "mfa-changed" }); } // ── Step-Up Verification ────────────────────────────────────────────────── [HttpGet] [AllowAnonymous] - public IActionResult Verify2FA(string returnUrl = null) + public async Task Verify2FA(string returnUrl = null, string scope = null, CancellationToken cancellationToken = default, + int? entry = null) + { + var methodScope = ParseScope(scope); + var entering = await EntryDepartmentAsync(entry); + return View(new StepUpVerifyViewModel + { + ReturnUrl = returnUrl, + Scope = methodScope.ToString(), + EntryDepartmentId = entering, + PasskeyAvailable = await PasskeyStepUpAvailableAsync(scope, cancellationToken, entering), + ApprovalAvailable = await ApprovalStepUpAvailableAsync(methodScope, cancellationToken, entering), + FederatedAvailable = entering == null && await FederatedStepUpAvailableAsync(methodScope, cancellationToken) + }); + } + + /// + /// The department being entered, whose switches then decide the methods (plan section 7.6 row 5: verify with a method the + /// target accepts before switching), when the user is a member of it; null for the active department. A display decision + /// that fails safe to the active department. + /// + private async Task EntryDepartmentAsync(int? entry) + { + if (entry is not int target || target <= 0) + return null; + + try + { + return target != DepartmentId && await _departments.IsMemberOfDepartmentAsync(target, UserId) ? target : null; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "The department being entered could not be checked; the active department's methods apply."); + return null; + } + } + + /// + /// Whether to offer provider step-up (plan section 7.8): Web SSO is set up, the session is tracked, the account signs in through + /// the department's provider under a tested mapping, and the scope accepts the provider's MFA here (never account factors). + /// + private async Task FederatedStepUpAvailableAsync(MfaMethodScope scope, CancellationToken cancellationToken) + { + try + { + var user = await _userManager.GetUserAsync(User); + if (user == null || scope == MfaMethodScope.Account || HttpProtectedGrantContext.SessionOf(HttpContext) == null || + !WebSsoRoundTrip.IsAvailable(_ssoBroker, _ssoReturnTargets)) + return false; + + return await _mfaPolicyService.IsMethodAcceptedAsync(DepartmentId, scope, MfaEvidenceMethod.Federated, cancellationToken) && + await _departmentSso.IsFederatedMfaAvailableAsync(DepartmentId, user.Id, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Provider step-up availability could not be read; the other methods are shown."); + return false; + } + } + + /// Verify2FA again after a refused code, with the same choices: they come from the server, never the form. + private async Task StepUpViewAsync(StepUpVerifyViewModel model, CancellationToken cancellationToken) + { + var methodScope = ParseScope(model.Scope); + var entering = await EntryDepartmentAsync(model.EntryDepartmentId); + model.Scope = methodScope.ToString(); + model.EntryDepartmentId = entering; + model.PasskeyAvailable = await PasskeyStepUpAvailableAsync(model.Scope, cancellationToken, entering); + model.ApprovalAvailable = await ApprovalStepUpAvailableAsync(methodScope, cancellationToken, entering); + model.FederatedAvailable = entering == null && await FederatedStepUpAvailableAsync(methodScope, cancellationToken); + return View(nameof(Verify2FA), model); + } + + private static MfaMethodScope ParseScope(string scope) => + Enum.TryParse(scope, true, out var parsed) && Enum.IsDefined(parsed) ? parsed : MfaMethodScope.Login; + + /// + /// Whether to offer Responder approval at step-up (plan section 7.9): a tracked session, an eligible Responder of the user's, + /// and a scope that accepts approval in the active department (never security changes or account factors). Display only. + /// + private async Task ApprovalStepUpAvailableAsync(MfaMethodScope scope, CancellationToken cancellationToken, int? department = null) + { + try + { + var user = await _userManager.GetUserAsync(User); + if (user == null || HttpProtectedGrantContext.SessionOf(HttpContext) == null) + return false; + + return await _approvals.IsAvailableAsync(user.Id, UserSessionClientApplication.Web, cancellationToken) && + await _mfaPolicyService.IsMethodAcceptedAsync(department ?? DepartmentId, scope, MfaEvidenceMethod.PasskeyApproval, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Approval step-up availability could not be read; the other methods are shown."); + return false; + } + } + + /// Asks the user's Responder to approve this step-up; the number is shown on this page only. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify2FARequestApproval([FromForm] string scope, CancellationToken cancellationToken, [FromForm] int? entry = null) + { + var user = await _userManager.GetUserAsync(User); + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (user == null || session == null) + return Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(MfaApprovalOutcome.SessionRequired) }); + + var start = await _approvals.RequestAsync(new MfaApprovalRequester + { + UserId = user.Id, + Kind = MfaApprovalRequesterKind.Session, + RequesterId = session.SessionId, + ClientApplication = UserSessionClientApplication.Web, + AuthenticationGeneration = session.AuthenticationGeneration, + DepartmentId = await EntryDepartmentAsync(entry) ?? DepartmentId, + Purpose = MfaApprovalPurpose.StepUp, + Operation = MfaStepUpOperations.ForScope(ParseScope(scope)), + SharedMode = session.SharedMode, + LockVersion = session.SessionLockVersion, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + UserName = user.UserName, + AuditSystem = SystemAuditSystems.Website + }, cancellationToken); + return start.Succeeded + ? Json(new { success = true, approvalRequestId = start.ApprovalRequestId, matchNumber = start.MatchNumber, expiresIn = start.ExpiresInSeconds }) + : Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(start.Outcome) ?? "approval_unavailable" }); + } + + /// The state of this session's own step-up approval request. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify2FAApprovalStatus([FromForm] string approvalRequestId, CancellationToken cancellationToken) { - return View(new StepUpVerifyViewModel { ReturnUrl = returnUrl }); + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (session == null) + return Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(MfaApprovalOutcome.SessionRequired) }); + + var found = await _approvals.GetForRequesterAsync(approvalRequestId, MfaApprovalRequesterKind.Session, session.SessionId, cancellationToken); + return found.Succeeded + ? Json(new { success = true, state = MfaApprovalOutcomes.StateName(found.Request.EffectiveState(DateTime.UtcNow)) }) + : Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(found.Outcome) ?? "approval_unavailable" }); + } + + /// + /// Uses this session's approved step-up request once (plan section 7.9 step 6): it must be for this scope's operation, at the + /// session's lock version, where the department still accepts approval. The evidence names the approving passkey and + /// Responder session and carries the approval time; the user returns to the local page that asked. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify2FACompleteApproval([FromForm] string approvalRequestId, [FromForm] string scope, [FromForm] string returnUrl, + CancellationToken cancellationToken, [FromForm] int? entry = null) + { + var user = await _userManager.GetUserAsync(User); + var session = HttpProtectedGrantContext.SessionOf(HttpContext); + if (user == null || session == null) + return Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(MfaApprovalOutcome.SessionRequired) }); + + var methodScope = ParseScope(scope); + if (!await _mfaPolicyService.IsMethodAcceptedAsync(await EntryDepartmentAsync(entry) ?? DepartmentId, methodScope, MfaEvidenceMethod.PasskeyApproval, + cancellationToken)) + return Json(new { success = false, error = "mfa_method_not_allowed" }); + + var found = await _approvals.GetForRequesterAsync(approvalRequestId, MfaApprovalRequesterKind.Session, session.SessionId, cancellationToken); + if (found.Succeeded && (found.Request.RequestPurpose != MfaApprovalPurpose.StepUp || + !string.Equals(found.Request.Operation, MfaStepUpOperations.ForScope(methodScope), StringComparison.Ordinal) || + found.Request.LockVersion != session.SessionLockVersion)) + return Json(new { success = false, error = "approval_expired" }); + + var consumed = await _approvals.ConsumeAsync(approvalRequestId, MfaApprovalRequesterKind.Session, session.SessionId, user.Id, + session.AuthenticationGeneration, cancellationToken); + if (!consumed.Succeeded) + return Json(new { success = false, error = MfaApprovalOutcomes.ErrorCode(consumed.Outcome) ?? "approval_unavailable" }); + + var approval = consumed.Request; + if (!await RecordEvidenceAsync(user, MfaEvidenceKind.SecondFactor, MfaEvidenceMethod.PasskeyApproval, MfaEvidencePurpose.StepUp, + approval.DecidedOnUtc ?? DateTime.UtcNow, cancellationToken, MfaApprovalRequest.FactorReferenceFor(approval.ApproverPasskeyId, approval.ApproverSessionId))) + return Json(new { success = false, error = "service_unavailable" }); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.TwoFactorStepUpVerified, + UserId = user.Id, + Username = user.UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = "Step-up verified with a Responder approval." + }, cancellationToken); + + return Json(new + { + success = true, + redirect = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : Url.Action("Dashboard", "Home", new { area = "User" }) + }); + } + + /// + /// Whether to offer a passkey at step-up: the user has one for the web, and the scope of the action that sent them here + /// accepts a passkey in the active department (passkey plan section 7.6 rows 7 and 13-15). A display decision only; + /// the guarded action checks the evidence it gets. + /// + private async Task PasskeyStepUpAvailableAsync(string scope, CancellationToken cancellationToken, int? department = null) + { + try + { + var user = await _userManager.GetUserAsync(User); + if (user == null || HttpProtectedGrantContext.SessionOf(HttpContext) == null) + return false; + + var methodScope = ParseScope(scope); + return await _passkeys.HasActiveForClientAsync(user.Id, UserSessionClientApplication.Web, cancellationToken) && + await _mfaPolicyService.IsMethodAcceptedAsync(department ?? DepartmentId, methodScope, MfaEvidenceMethod.Passkey, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Passkey step-up availability could not be read; the code prompt is shown alone."); + return false; + } + } + + /// Assertion options for a Web passkey, bound to this session, for a sensitive operation (step-up). + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify2FAPasskeyOptions(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return Json(new { success = false, error = PasskeyOutcomes.ErrorCode(PasskeyOutcome.SessionRequired) }); + + var start = await _passkeys.BeginAssertionAsync(PasskeyCaller(user), AuthenticationChallengePurpose.SensitiveOperation, cancellationToken); + return start.Succeeded + ? Json(new { success = true, requestId = start.RequestId, options = start.OptionsJson }) + : Json(new { success = false, error = PasskeyOutcomes.ErrorCode(start.Outcome) }); + } + + /// + /// Verifies a Web passkey for step-up and records it as this session's second-factor evidence, then sends the user + /// back to the local page that asked (passkey plan section 7.5 rule 2: Verify2FA accepts either method). + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Verify2FAPasskey([FromForm] string requestId, [FromForm] string credential, [FromForm] string returnUrl, + CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + if (user == null) return Json(new { success = false, error = PasskeyOutcomes.ErrorCode(PasskeyOutcome.SessionRequired) }); + if (string.IsNullOrWhiteSpace(requestId) || string.IsNullOrWhiteSpace(credential)) + return Json(new { success = false, error = PasskeyOutcomes.ErrorCode(PasskeyOutcome.InvalidRequest) }); + + var assertion = await _passkeys.CompleteAssertionAsync(PasskeyCaller(user), AuthenticationChallengePurpose.SensitiveOperation, requestId, credential, + cancellationToken); + if (!assertion.Succeeded) + { + if (assertion.Outcome is PasskeyOutcome.VerificationFailed or PasskeyOutcome.NotRegisteredForClient) + await _mfaActivity.RecordAsync(new MfaActivityEntry + { + UserId = user.Id, Method = MfaEvidenceMethod.Passkey, Purpose = MfaEvidencePurpose.StepUp, Successful = false, + ClientApplication = UserSessionClientApplication.Web, SessionId = MfaEvidence.TrackedSessionId(MfaEvidenceSession.KeyFor(User, HttpContext)) + }, cancellationToken); + return Json(new { success = false, error = PasskeyOutcomes.ErrorCode(assertion.Outcome) }); + } + + if (!await RecordEvidenceAsync(user, MfaEvidenceKind.SecondFactor, MfaEvidenceMethod.Passkey, MfaEvidencePurpose.StepUp, assertion.VerifiedOnUtc, + cancellationToken, UserPasskey.FactorReferenceFor(assertion.Passkey.UserPasskeyId))) + return Json(new { success = false, error = "service_unavailable" }); + + await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit + { + System = (int)SystemAuditSystems.Website, + Type = (int)SystemAuditTypes.TwoFactorStepUpVerified, + UserId = user.Id, + Username = user.UserName, + Successful = true, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + Data = "Step-up verified with a passkey." + }, cancellationToken); + + return Json(new + { + success = true, + redirect = !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : Url.Action("Dashboard", "Home", new { area = "User" }) + }); } + private PasskeyCaller PasskeyCaller(IdentityUser user) => + Resgrid.Model.Security.PasskeyCaller.From(HttpProtectedGrantContext.SessionOf(HttpContext), user.Id, user.UserName, DepartmentId, + SystemAuditSystems.Website, IpAddressHelper.GetRequestIP(Request, true)); + [HttpPost] [ValidateAntiForgeryToken] [AllowAnonymous] public async Task Verify2FA(StepUpVerifyViewModel model, CancellationToken cancellationToken) { - if (!ModelState.IsValid) return View(model); + if (!ModelState.IsValid) return await StepUpViewAsync(model, cancellationToken); var user = await _userManager.GetUserAsync(User); if (user == null) return Challenge(); + // Step-up codes share the account lockout with sign-in (passkey plan section 7.5 rule 6): a session that + // already holds the password does not get unlimited guesses at the second factor. + if (await _userManager.IsLockedOutAsync(user)) + { + ModelState.AddModelError(nameof(model.Code), "Too many failed attempts. Wait a few minutes and try again."); + return await StepUpViewAsync(model, cancellationToken); + } + var verificationCode = model.Code.Replace(" ", string.Empty).Replace("-", string.Empty); var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode); if (!isValid) { + await _userManager.AccessFailedAsync(user); + await _mfaActivity.RecordAsync(new MfaActivityEntry + { + UserId = user.Id, Method = MfaEvidenceMethod.Totp, Purpose = MfaEvidencePurpose.StepUp, Successful = false, + ClientApplication = UserSessionClientApplication.Web, SessionId = MfaEvidence.TrackedSessionId(MfaEvidenceSession.KeyFor(User, HttpContext)) + }, cancellationToken); ModelState.AddModelError(nameof(model.Code), "Verification code is invalid."); - return View(model); + return await StepUpViewAsync(model, cancellationToken); } - // Stamp session with the verified user id and time so the step-up proof - // is bound to this specific user and cannot be inherited by another user. - HttpContext.Session.SetString(RequiresRecentTwoFactorAttribute.StepUpSessionKey, - $"{user.Id}|{DateTime.UtcNow:O}"); + await _userManager.ResetAccessFailedCountAsync(user); + + // The proof is server-side evidence for this session and generation; RequiresRecentTwoFactor reads it. Without + // it the user would be sent straight back here, so say so instead of looping. + var verifiedAt = DateTime.UtcNow; + if (!await RecordEvidenceAsync(user, MfaEvidenceKind.SecondFactor, MfaEvidenceMethod.Totp, MfaEvidencePurpose.StepUp, verifiedAt, cancellationToken)) + { + ModelState.AddModelError(nameof(model.Code), "Your verification could not be recorded. Wait for the next code and try again."); + return await StepUpViewAsync(model, cancellationToken); + } await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit { @@ -289,6 +790,99 @@ await _systemAuditsService.SaveSystemAuditAsync(new SystemAudit // ── Helpers ─────────────────────────────────────────────────────────────── + /// A security notice to the account holder (passkey plan section 6.4); it never fails the change it reports. + private Task NoticeAsync(IdentityUser user, SecurityNoticeKind kind, CancellationToken cancellationToken) => + _securityNotices.QueueAsync(new SecurityNoticeRequest { UserId = user.Id, Kind = kind, ClientApplication = UserSessionClientApplication.Web }, + cancellationToken); + + private async Task StageNewAuthenticatorKeyAsync(IdentityUser user) + { + var key = _userManager.GenerateNewAuthenticatorKey(); + await _userManager.SetAuthenticationTokenAsync(user, StagedKeyLoginProvider, StagedKeyTokenName, + StagedAuthenticatorKey.Serialize(key, DateTime.UtcNow)); + return key; + } + + // A staged key is usable only for its short lifetime: it was authorized by the fresh first factor that staged it. + private async Task GetStagedAuthenticatorKeyAsync(IdentityUser user) + => StagedAuthenticatorKey.ReadUsableKey( + await _userManager.GetAuthenticationTokenAsync(user, StagedKeyLoginProvider, StagedKeyTokenName), + DateTime.UtcNow, TimeSpan.FromMinutes(Math.Max(1, TwoFactorConfig.StagedAuthenticatorLifetimeMinutes))); + + private async Task PromoteStagedAuthenticatorKeyAsync(IdentityUser user, string stagedKey, CancellationToken cancellationToken) + { + if (_userStore is not IUserAuthenticatorKeyStore keyStore) + throw new InvalidOperationException("The user store does not support authenticator keys."); + + await keyStore.SetAuthenticatorKeyAsync(user, stagedKey, cancellationToken); + await _userManager.RemoveAuthenticationTokenAsync(user, StagedKeyLoginProvider, StagedKeyTokenName); + await _mfaStateRepository.RecordTotpEnrollmentAsync(user.Id, DateTime.UtcNow, new TotpEnrollmentContext(false, (int)UserSessionClientApplication.Web), cancellationToken); + } + + private async Task BuildAuthenticatorModelAsync(IdentityUser user, string key, bool isReplacement) + { + var uri = GenerateQrCodeUri(await _userManager.GetEmailAsync(user), key); + return new EnableAuthenticatorViewModel + { + SharedKey = FormatKey(key), + AuthenticatorUri = uri, + QrCodeDataUrl = GenerateQrCodeDataUrl(uri), + IsReplacement = isReplacement + }; + } + + private async Task HasReplacementAuthorityAsync(IdentityUser user, CancellationToken cancellationToken) + { + var stepUp = await RequiresRecentTwoFactorAttribute.GetStepUpVerifiedAtUtcAsync(HttpContext, user, _mfaEvidenceService, + _mfaPolicyService, null, MfaMethodScope.Account); + if (stepUp.HasValue && stepUp.Value <= DateTime.UtcNow && DateTime.UtcNow - stepUp.Value <= TimeSpan.FromMinutes(ReplaceStepUpWindowMinutes)) + return true; + + // Lost-authenticator journey: a recent recovery-code sign-in may replace the factor, and nothing else sensitive. + var sessionId = User.FindFirst(SessionClaimTypes.SessionId)?.Value; + if (string.IsNullOrWhiteSpace(sessionId)) + return false; + + var session = (await _userSessionService.GetActiveForUserAsync(user.Id, cancellationToken)) + .FirstOrDefault(s => s.UserSessionId == sessionId); + return session != null + && session.AuthenticationMethod == UserSessionAuthenticationMethod.Recovery + && DateTime.UtcNow - session.CreatedOn <= TimeSpan.FromMinutes(ReplaceRecoverySessionWindowMinutes); + } + + private Task HasFreshFirstFactorAsync(IdentityUser user, int maxAgeMinutes) + => _mfaEvidenceService.HasFreshFirstFactorAsync(user.Id, MfaEvidenceSession.KeyFor(User, HttpContext), + user.AuthenticationGeneration, TimeSpan.FromMinutes(Math.Max(1, maxAgeMinutes)), DateTime.UtcNow); + + /// Sends the user to confirm their password, then back to (default: this page). + private IActionResult RedirectToReauthenticate(string returnUrl = null) + => RedirectToAction("Reauthenticate", "AccountSecurity", + new { area = "User", returnUrl = returnUrl ?? $"{Request.Path}{Request.QueryString}" }); + + /// Records server-side evidence for this session; false when it could not be recorded. + private async Task RecordEvidenceAsync(IdentityUser user, MfaEvidenceKind kind, MfaEvidenceMethod method, + MfaEvidencePurpose purpose, DateTime verifiedOnUtc, CancellationToken cancellationToken, string factorReference = null) + { + var sessionKey = MfaEvidenceSession.KeyFor(User, HttpContext); + if (sessionKey == null) + return false; + + try + { + await _mfaEvidenceService.RecordAsync(user.Id, sessionKey, UserSessionClientApplication.Web, kind, method, purpose, + verifiedOnUtc, user.AuthenticationGeneration, factorReference: factorReference, cancellationToken: cancellationToken); + return true; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logging.LogException(ex, "Failed to record MFA evidence."); + return false; + } + } + + private IActionResult RedirectToStepUp() + => RedirectToAction(nameof(Verify2FA), new { returnUrl = Url.Action(nameof(ReplaceAuthenticator)) }); + private static string FormatKey(string unformattedKey) { var result = new StringBuilder(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index 82ba4b179..1f4a7b44a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -146,31 +146,7 @@ public async Task Index() tree1.icon = ""; trees.Add(tree1); - if (model.Groups != null && model.Groups.Any()) - { - foreach (var topLevelGroup in model.Groups.Where(x => !x.ParentDepartmentGroupId.HasValue).ToList()) - { - var group = new BSTreeModel(); - group.id = $"TreeGroup_{topLevelGroup.DepartmentGroupId.ToString()}"; - group.text = topLevelGroup.Name; - group.icon = ""; - - if (topLevelGroup.Children != null && topLevelGroup.Children.Any()) - { - foreach (var secondLevelGroup in topLevelGroup.Children) - { - var secondLevelGroupTree = new BSTreeModel(); - secondLevelGroupTree.id = $"TreeGroup_{secondLevelGroup.DepartmentGroupId.ToString()}"; - secondLevelGroupTree.text = secondLevelGroup.Name; - secondLevelGroupTree.icon = ""; - - group.nodes.Add(secondLevelGroupTree); - } - } - - trees.Add(group); - } - } + trees.AddRange(BSTreeModel.ForDepartmentGroups(model.Groups)); model.TreeData = Newtonsoft.Json.JsonConvert.SerializeObject(trees); return View(model); diff --git a/Web/Resgrid.Web/Areas/User/Models/BSTreeModel.cs b/Web/Resgrid.Web/Areas/User/Models/BSTreeModel.cs index 4fe503de8..baf452683 100644 --- a/Web/Resgrid.Web/Areas/User/Models/BSTreeModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/BSTreeModel.cs @@ -1,4 +1,7 @@ using System.Collections.Generic; +using System.Linq; +using System.Net; +using Resgrid.Model; namespace Resgrid.WebCore.Areas.User.Models { @@ -16,5 +19,32 @@ public BSTreeModel() { nodes = new List(); } + + /// + /// Nodes for a department's groups, as the Units and Personnel list pages show them: each top-level group + /// with its direct children. bstreeview appends node text as HTML, so the user-entered group names are + /// HTML-encoded here. + /// + public static List ForDepartmentGroups(IEnumerable groups) + { + var result = new List(); + if (groups == null) + return result; + + foreach (var topLevelGroup in groups.Where(x => !x.ParentDepartmentGroupId.HasValue)) + { + var group = new BSTreeModel { id = $"TreeGroup_{topLevelGroup.DepartmentGroupId}", text = WebUtility.HtmlEncode(topLevelGroup.Name), icon = "" }; + + if (topLevelGroup.Children != null) + { + foreach (var secondLevelGroup in topLevelGroup.Children) + group.nodes.Add(new BSTreeModel { id = $"TreeGroup_{secondLevelGroup.DepartmentGroupId}", text = WebUtility.HtmlEncode(secondLevelGroup.Name), icon = "" }); + } + + result.Add(group); + } + + return result; + } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Search/SearchIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/Search/SearchIndexView.cs new file mode 100644 index 000000000..dcf504b33 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Search/SearchIndexView.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace Resgrid.WebCore.Areas.User.Models.Search +{ + /// The search page: the query as entered, the families the caller may narrow to, and one page of authorized hits. + public class SearchIndexView + { + public string Query { get; set; } + + /// The selected family (a SearchEntityTypes value), or null for every family. + public string Type { get; set; } + + /// Department-local dates as entered (yyyy-MM-dd). + public string From { get; set; } + public string To { get; set; } + + public string Sort { get; set; } + + public int Page { get; set; } = 1; + public int PageSize { get; set; } + + /// Families the caller may search, in display order. + public List Families { get; set; } = new List(); + + public List Results { get; set; } = new List(); + + /// Authorized total, or null when it cannot be proven (see UnifiedSearchResult.Total). + public int? Total { get; set; } + + public bool HasMore { get; set; } + + public bool Searched { get; set; } + + /// Search is off for the department or the caller may not search any family. + public bool Unavailable { get; set; } + + public bool Degraded { get; set; } + + public bool IndexBuilding { get; set; } + + /// The From/To input could not be read as a date; the filter was ignored. + public bool InvalidDate { get; set; } + + public int MaxExportRows { get; set; } + + /// Words and phrases to mark in titles and excerpts. + public List HighlightTerms { get; set; } = new List(); + + public int FirstIndex => (Page - 1) * PageSize + 1; + public int LastIndex => FirstIndex + Results.Count - 1; + } + + public class SearchResultRow + { + public string EntityType { get; set; } + public string Title { get; set; } + public string Url { get; set; } + public string Summary { get; set; } + public string Snippet { get; set; } + /// Department-local, formatted. + public string OccurredOn { get; set; } + public string Status { get; set; } + public string Category { get; set; } + /// Call number, for calls. + public string Number { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs index 279a56b4d..df28e8d48 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/AccountCredentialViews.cs @@ -17,6 +17,22 @@ public class ChangeUsernameView public string CurrentPassword { get; set; } } + /// Confirms the signed-in user's password before a credential change (passkey plan section 6.2). + public class ReauthenticateView + { + public string ReturnUrl { get; set; } + + /// True when this account cannot confirm with a Resgrid password (SSO-managed or SSO required). + public bool PasswordNotAllowed { get; set; } + + /// True when the department's provider can confirm who this is instead (Web SSO is set up for this department). + public bool SsoAvailable { get; set; } + + [Required, DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } + } + public class ChangePasswordView { public bool IsSsoManaged { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/PermissionOptionLabels.cs b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionOptionLabels.cs new file mode 100644 index 000000000..918e7cf35 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionOptionLabels.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Localization; + +namespace Resgrid.Web.Areas.User.Models.Security +{ + /// + /// Display text for the permission action dropdowns on the Permissions screen, shared by the fixed rows + /// built in SecurityController.Index and the generated . The + /// permission notes name these options, so both must come from the same Security resources. + /// + public class PermissionOptionLabels + { + public const string EveryoneKey = "PermOptionEveryone"; + public const string DepartmentAdminsKey = "PermOptionDepartmentAdmins"; + public const string DepartmentAndGroupAdminsKey = "PermOptionDepartmentAndGroupAdmins"; + public const string DepartmentAdminsAndSelectRolesKey = "PermOptionDepartmentAdminsAndSelectRoles"; + public const string DepartmentGroupAdminsAndSelectRolesKey = "PermOptionDepartmentGroupAdminsAndSelectRoles"; + + public static readonly string[] Keys = + { + EveryoneKey, DepartmentAdminsKey, DepartmentAndGroupAdminsKey, DepartmentAdminsAndSelectRolesKey, DepartmentGroupAdminsAndSelectRolesKey + }; + + /// Used when no localizer is available (tests and other non-request callers). + public static readonly PermissionOptionLabels English = new PermissionOptionLabels + { + Everyone = "Everyone", + DepartmentAdmins = "Department Admins", + DepartmentAndGroupAdmins = "Department and Group Admins", + DepartmentAdminsAndSelectRoles = "Department Admins and Select Roles", + DepartmentGroupAdminsAndSelectRoles = "Department, Group Admins and Select Roles" + }; + + /// PermissionActions 3. + public string Everyone { get; set; } + /// PermissionActions 0. + public string DepartmentAdmins { get; set; } + /// PermissionActions 1. + public string DepartmentAndGroupAdmins { get; set; } + /// PermissionActions 2. + public string DepartmentAdminsAndSelectRoles { get; set; } + /// PermissionActions 4, offered on Records rows only. + public string DepartmentGroupAdminsAndSelectRoles { get; set; } + + public static PermissionOptionLabels From(IStringLocalizer localizer) + { + return new PermissionOptionLabels + { + Everyone = localizer[EveryoneKey].Value, + DepartmentAdmins = localizer[DepartmentAdminsKey].Value, + DepartmentAndGroupAdmins = localizer[DepartmentAndGroupAdminsKey].Value, + DepartmentAdminsAndSelectRoles = localizer[DepartmentAdminsAndSelectRolesKey].Value, + DepartmentGroupAdminsAndSelectRoles = localizer[DepartmentGroupAdminsAndSelectRolesKey].Value + }; + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs index 41b46838b..4e5b3c9b7 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/RecordsPermissionRow.cs @@ -35,7 +35,7 @@ public static class RecordsPermissionRows public const string EveryoneValue = "3"; public const string DepartmentAndGroupAdminsAndSelectRolesValue = "4"; - public static List Build(IEnumerable permissions, IEnumerable descriptors = null) + public static List Build(IEnumerable permissions, IEnumerable descriptors = null, PermissionOptionLabels labels = null) { var existing = (permissions ?? Enumerable.Empty()).Where(p => p != null).ToList(); var rows = new List(); @@ -54,7 +54,7 @@ public static List Build(IEnumerable permissio HasRow = row != null, LockToGroup = row != null ? row.LockToGroup : descriptor.Type == PermissionTypes.ViewChecklistResults, ShowLockToGroup = descriptor.LockToGroupMeaningful, - Options = BuildOptions(descriptor.EveryoneOffered, value) + Options = BuildOptions(descriptor.EveryoneOffered, value, labels) }); } @@ -64,20 +64,22 @@ public static List Build(IEnumerable permissio /// /// The action dropdown. Value 4 (department and group admins plus selected roles) is offered on every /// Records row; "Everyone" only where the catalog allows it. A stored value that the catalog would not - /// offer is still listed so the dropdown never misrepresents what is saved. + /// offer is still listed so the dropdown never misrepresents what is saved. Option text comes from + /// (the localized Security resources), falling back to English. /// - public static SelectList BuildOptions(bool includeEveryone, int selected) + public static SelectList BuildOptions(bool includeEveryone, int selected, PermissionOptionLabels labels = null) { + labels ??= PermissionOptionLabels.English; var options = new List(); var selectedValue = selected.ToString(); if (includeEveryone || selectedValue == EveryoneValue) - options.Add(new SelectListItem { Value = EveryoneValue, Text = "Everyone" }); + options.Add(new SelectListItem { Value = EveryoneValue, Text = labels.Everyone }); - options.Add(new SelectListItem { Value = "0", Text = "Department Admins" }); - options.Add(new SelectListItem { Value = "1", Text = "Department and Group Admins" }); - options.Add(new SelectListItem { Value = "2", Text = "Department Admins and Select Roles" }); - options.Add(new SelectListItem { Value = DepartmentAndGroupAdminsAndSelectRolesValue, Text = "Department, Group Admins and Select Roles" }); + options.Add(new SelectListItem { Value = "0", Text = labels.DepartmentAdmins }); + options.Add(new SelectListItem { Value = "1", Text = labels.DepartmentAndGroupAdmins }); + options.Add(new SelectListItem { Value = "2", Text = labels.DepartmentAdminsAndSelectRoles }); + options.Add(new SelectListItem { Value = DepartmentAndGroupAdminsAndSelectRolesValue, Text = labels.DepartmentGroupAdminsAndSelectRoles }); return new SelectList(options, "Value", "Text", selectedValue); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/SsoViews.cs b/Web/Resgrid.Web/Areas/User/Models/Security/SsoViews.cs index 2015503f0..97c257806 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/SsoViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/SsoViews.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; +using Resgrid.Model.Security; namespace Resgrid.Web.Areas.User.Models.Security { @@ -61,6 +64,7 @@ public class SsoConfigEditView public string MetadataUrl { get; set; } public string EntityId { get; set; } public string AssertionConsumerServiceUrl { get; set; } + public string IdpSsoUrl { get; set; } public string IdpCertificate { get; set; } public string SigningCertificate { get; set; } @@ -80,6 +84,17 @@ public class SsoConfigEditView // ── Context for the view ────────────────────────────────────────────── public string AcsUrl { get; set; } public string ApiBaseUrl { get; set; } + + /// The OIDC redirect URI for brokered sign-in, which the department registers with its IdP. + public string OidcBrokerRedirectUri { get; set; } + + /// + /// Each app's own redirect URIs for sign-in the app runs itself (older app versions, or while brokered sign-in is off): + /// its native scheme, and its web edition's page where this deployment serves one. The department registers every one + /// with its IdP; an app can only receive a redirect on an address it owns. + /// + public IReadOnlyList OidcAppRedirectUris => + LegacyAppCallbacks.RedirectUris(Resgrid.Config.SsoConfig.AppWebOrigins); } /// View model for the security policy page. @@ -112,6 +127,96 @@ public class SecurityPolicyEditView public int DataClassificationLevel { get; set; } public SelectList DataClassificationLevels { get; set; } + + // Second-factor methods (passkey plan section 10.1). Defaults match a department with no policy row. + public bool AllowPasskeysForLoginMfa { get; set; } = true; + public bool AllowPasskeysForAdp { get; set; } = true; + public bool AllowFederatedMfaForLoginMfa { get; set; } + public bool AllowFederatedMfaForAdp { get; set; } + public bool AllowResponderApproval { get; set; } = true; + public bool AcceptRecentLoginMfaForAdp { get; set; } = true; + public bool AcceptRecentUnlockMfaForAdp { get; set; } = true; + + /// Only the managing member changes which methods are accepted; other administrators see them read-only. + public bool CanChangeMethodSwitches { get; set; } + + /// Whether this deployment accepts passkeys yet; the switches take effect once it does. + public bool PasskeysAvailable { get; set; } + public bool ResponderApprovalAvailable { get; set; } + public bool ProviderStepUpAvailable { get; set; } + + // ── Shared vehicle and workstation devices (passkey plan section 10.5); managing member only ── + + public int SharedIdleLockMinutes { get; set; } = SharedSessionRules.DefaultIdleLockMinutes; + public int SharedShiftHours { get; set; } = SharedSessionRules.DefaultShiftHours; + public bool RequireSharedModeForUnit { get; set; } + public bool RequireSharedModeForCommand { get; set; } + public bool RequireSharedModeForDispatch { get; set; } + + /// Whether this deployment offers shared-device mode yet; a new requirement can only be added once it does. + public bool SharedDeviceModeAvailable { get; set; } + public int MaxSharedIdleLockMinutes { get; set; } + public int MaxSharedShiftHours { get; set; } + + public int SharedModeRequiredApps => + (RequireSharedModeForUnit ? (int)SharedModeApps.Unit : 0) | + (RequireSharedModeForCommand ? (int)SharedModeApps.Command : 0) | + (RequireSharedModeForDispatch ? (int)SharedModeApps.Dispatch : 0); + } + + /// + /// View model for the provider step-up mapping page (passkey plan section 7.8). The value lists are edited one value per + /// line; everything about the stored mapping (its version, test and who may change it) comes from the server, never the form. + /// + public class FederatedMfaEditView + { + public string RequestAcrValues { get; set; } + public string RequestClaims { get; set; } + public string RequestAuthnContextClassRefs { get; set; } + public string AcceptAmr { get; set; } + public string AcceptAcr { get; set; } + public string AcceptAcrs { get; set; } + public string AcceptAuthnContextClassRefs { get; set; } + + public bool HasActiveSsoConfig { get; set; } + public bool IsOidc { get; set; } + public bool CanChange { get; set; } + public bool ProviderStepUpAvailable { get; set; } + public bool HasMapping { get; set; } + public long MappingVersion { get; set; } + public bool Effective { get; set; } + public DateTime? TestedOnUtc { get; set; } + + /// The mapping the form describes. Blank lines are dropped; everything else is left for validation to judge. + public Resgrid.Model.Security.FederatedMfaMapping ToMapping() => new() + { + RequestAcrValues = Values(RequestAcrValues), + RequestClaims = string.IsNullOrWhiteSpace(RequestClaims) ? null : RequestClaims.Trim(), + RequestAuthnContextClassRefs = Values(RequestAuthnContextClassRefs), + AcceptAmr = Values(AcceptAmr), + AcceptAcr = Values(AcceptAcr), + AcceptAcrs = Values(AcceptAcrs), + AcceptAuthnContextClassRefs = Values(AcceptAuthnContextClassRefs) + }; + + public void CopyFrom(Resgrid.Model.Security.FederatedMfaMapping mapping) + { + RequestAcrValues = Lines(mapping?.RequestAcrValues); + RequestClaims = mapping?.RequestClaims; + RequestAuthnContextClassRefs = Lines(mapping?.RequestAuthnContextClassRefs); + AcceptAmr = Lines(mapping?.AcceptAmr); + AcceptAcr = Lines(mapping?.AcceptAcr); + AcceptAcrs = Lines(mapping?.AcceptAcrs); + AcceptAuthnContextClassRefs = Lines(mapping?.AcceptAuthnContextClassRefs); + } + + private static List Values(string lines) + { + var values = (lines ?? string.Empty).Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToList(); + return values.Count == 0 ? null : values; + } + + private static string Lines(IEnumerable values) => values == null ? null : string.Join("\n", values); } /// View model for the SCIM setup page. diff --git a/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs index 07aa983b5..ccb6e8fbd 100644 --- a/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/TwoFactor/TwoFactorViewModels.cs @@ -19,11 +19,17 @@ public class EnableAuthenticatorViewModel [DataType(DataType.Text)] [Display(Name = "Verification Code")] public string Code { get; set; } + + /// True when this form replaces an enrolled authenticator rather than setting up the first one. + public bool IsReplacement { get; set; } } public class ShowRecoveryCodesViewModel { public IEnumerable RecoveryCodes { get; set; } + + /// True after a factor change that signed every session out; the page offers sign-in instead of the MFA index. + public bool SignInAgainRequired { get; set; } } public class Disable2FAViewModel @@ -33,6 +39,9 @@ public class Disable2FAViewModel [DataType(DataType.Text)] [Display(Name = "Verification Code")] public string Code { get; set; } + + /// Set by the server: TOTP cannot be turned off while the user has passkeys. + public bool BlockedByPasskeys { get; set; } } public class TwoFactorIndexViewModel @@ -41,6 +50,28 @@ public class TwoFactorIndexViewModel public bool Is2FAEnabled { get; set; } public int RecoveryCodesLeft { get; set; } public bool RecoveryCodeWarning { get; set; } + + /// The user's passkeys in every app (passkey plan section 6.5); any of them can be renamed or removed here. + public System.Collections.Generic.List Passkeys { get; set; } = new(); + + /// Whether a passkey for the web can be added on this deployment now. + public bool WebPasskeyRegistrationAvailable { get; set; } + + /// The outcome of the last passkey change on this page: added, renamed or removed. + public string PasskeyStatus { get; set; } + } + + public class PasskeyRowView + { + public string Id { get; set; } + public string DisplayName { get; set; } + + /// The localization key naming the app the passkey works in. + public string AppLabelKey { get; set; } + + public System.DateTime CreatedOn { get; set; } + public System.DateTime? LastUsedOn { get; set; } + public bool CreatedOnSharedInstallation { get; set; } } public class VerifyRecoveryCodeViewModel @@ -60,6 +91,21 @@ public class StepUpVerifyViewModel public string Code { get; set; } public string ReturnUrl { get; set; } + + /// Offer "Use a passkey": the user has one for the web and the guarded action's scope accepts it here. + public bool PasskeyAvailable { get; set; } + + /// Offer "Approve with Responder": the user has an eligible Responder and the scope accepts approval here. + public bool ApprovalAvailable { get; set; } + + /// Offer provider step-up: the account signs in through the department's provider and the scope accepts its MFA here. + public bool FederatedAvailable { get; set; } + + /// The guarded action's scope, as the guard named it (a display hint; every command checks it again). + public string Scope { get; set; } + + /// The department being entered, when this verification is for entering it (plan section 7.6 row 5). + public int? EntryDepartmentId { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml new file mode 100644 index 000000000..500e3ee5c --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/AccountSecurity/Reauthenticate.cshtml @@ -0,0 +1,38 @@ +@model Resgrid.Web.Areas.User.Models.Security.ReauthenticateView +@inject Microsoft.Extensions.Localization.IStringLocalizer twoFactorLocalizer +@{ + ViewBag.Title = "Confirm Your Password"; + Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; +} +

Confirm your password

+
+
For your security, confirm your password before changing how you sign in. This does not sign you out or extend your session.
+ @if (TempData["StepUpMessage"] is string stepUpMessage) + { +
@stepUpMessage
+ } + @if (Model.PasswordNotAllowed) + { +
This account signs in through your organization's single sign-on. Sign out and sign in again with single sign-on to continue.
+ } +
+ @Html.AntiForgeryToken() + +
+
+
Cancel + @if (Model.SsoAvailable) + { + + } +
+
+ @if (Model.SsoAvailable) + { +
+ @Html.AntiForgeryToken() + + +
+ } +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml index 4dace9d3b..423417b63 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml @@ -98,7 +98,7 @@ } @if (status is Resgrid.Model.Invoicing.BidStatuses.Draft or Resgrid.Model.Invoicing.BidStatuses.Submitted) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} @if (status == Resgrid.Model.Invoicing.BidStatuses.Accepted && !b.IsConverted && Model.CanManageDeployments) { @@ -106,7 +106,7 @@ } @if (status == Resgrid.Model.Invoicing.BidStatuses.Draft) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} diff --git a/Web/Resgrid.Web/Areas/User/Views/BusinessOperationsBilling/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/BusinessOperationsBilling/Index.cshtml index 4c387cd5f..25721f630 100644 --- a/Web/Resgrid.Web/Areas/User/Views/BusinessOperationsBilling/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/BusinessOperationsBilling/Index.cshtml @@ -54,7 +54,7 @@ } @if (Model.CanCancel && !Model.Cancelled) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} } diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml index 8cbd2bc49..0be6cbe05 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Agreements.cshtml @@ -43,7 +43,7 @@ @if (Model.IsManager) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml index ab9e2ae53..6ae895ce2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Rate.cshtml @@ -69,7 +69,7 @@ } @if (p.IsEditable) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} @if (Model.CanSubmit && p.Status >= (int)Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsRateProfileStatuses.SignedLocally) { @@ -125,7 +125,7 @@ {
@localizer["SalarySurveyFromWorkforceHelp"] -
+ @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml index bbb3b864e..7f74bceb8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/Resources.cshtml @@ -46,7 +46,7 @@ @localizer["ReviewState" + (Resgrid.Model.CostRecovery.CalOesMars.CalOesMarsReviewStates)r.ReviewState] @if (!r.IsCurrent(today)) { @localizer["NotCurrent"] } - @Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} diff --git a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml index af4fb0a3a..a5f42296a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CalOesMars/WorkItem.cshtml @@ -258,7 +258,7 @@ } @if (!w.IsExternal) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
}
diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml index 892ae7e77..57a27b208 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/View.cshtml @@ -98,14 +98,14 @@ @if (Model.CanEdit && Model.IsRecurrenceParent == false) { + class="btn btn-danger" onclick="return confirm('@JsEncoder.Encode(localizer["DeleteEventConfirm"])');"> @localizer["DeleteEvent"] } else if (Model.CanEdit) { + class="btn btn-danger" onclick="return confirm('@JsEncoder.Encode(localizer["DeleteEventOccurrencesConfirm"])');"> @localizer["DeleteEventOccurrences"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml index 816c42f26..8b1dae48b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Record.cshtml @@ -127,7 +127,7 @@ @if (Model.CanManage) { -
@Html.AntiForgeryToken()
+
@Html.AntiForgeryToken()
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml index a1df603c5..590b4fdcb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Types.cshtml @@ -50,7 +50,7 @@ @localizer["Edit"]
@Html.AntiForgeryToken() - +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Certifications/Unit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Certifications/Unit.cshtml index 71052b19e..d084b6904 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Certifications/Unit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Certifications/Unit.cshtml @@ -84,7 +84,7 @@ }
@Html.AntiForgeryToken() - +
} diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml index abfc69040..ac2bb1fcd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Index.cshtml @@ -96,7 +96,7 @@
@Html.AntiForgeryToken() - +
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml index a1b5c9ad3..d4f39afd3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Attachments.cshtml @@ -117,7 +117,7 @@ @localizer["Download"] @if (canDelete) { -
+ @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml index 505595562..23fec342b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Index.cshtml @@ -54,7 +54,7 @@ if (Model.Contacts != null && Model.Contacts.Any()) { @Html.Raw("
") - @Html.Raw($"
") + @Html.Raw($"
{@commonLocalizer["Name"]}{@commonLocalizer["Type"]}{@commonLocalizer["Category"]}{@localizer["LastUpdated"]}
") foreach (var c in Model.Contacts) { @@ -69,7 +69,7 @@ } else { - @Html.Raw($"

{@localizer["NoContactsInDepartment"]}

") + @Html.Raw($"

{Html.Encode(localizer["NoContactsInDepartment"].Value)}

") } } @@ -84,7 +84,7 @@ { @Html.Raw("
{Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Type"].Value)}{Html.Encode(commonLocalizer["Category"].Value)}{Html.Encode(localizer["LastUpdated"].Value)}
") + @Html.Raw($"
{@commonLocalizer["Name"]}{@commonLocalizer["Type"]}{@commonLocalizer["Category"]}{@localizer["LastUpdated"]}
") foreach (var c in categoryContacts) @@ -100,7 +100,7 @@ else { @Html.Raw("") } } @@ -115,6 +115,8 @@ @{ void ContactsTableButtonTemplate(Contact c) { + // Razor does not encode text built inside Html.Raw: every contact field and label below goes through + // Html.Encode, which also escapes apostrophes for the single-quoted attributes. string timestamp; if (c.EditedOn.HasValue) { @@ -126,24 +128,24 @@ } var reviewBadge = Model.PreplanReviewOverdueContactIds != null && Model.PreplanReviewOverdueContactIds.Contains(c.ContactId) - ? " " + localizer["PreplanReviewDue"] + "" + ? " " + Html.Encode(localizer["PreplanReviewDue"].Value) + "" : ""; - @Html.Raw("") + @Html.Raw("") @Html.Raw("' + '' + ''; $body.append(row); }); @@ -406,7 +406,7 @@ $('#hazardLocation').val(h ? h.LocationDescription : ''); $('#hazardGps').val(h ? h.GpsCoordinates : ''); $('#hazardShouldAlert').prop('checked', h ? h.ShouldAlert : false); - $('#hazardModalLabel').text(h ? '@localizer["EditHazard"]' : '@localizer["AddHazard"]'); + $('#hazardModalLabel').text(h ? '@JsEncoder.Encode(localizer["EditHazard"])' : '@JsEncoder.Encode(localizer["AddHazard"])'); $('#hazardModal').modal('show'); } @@ -419,20 +419,20 @@ }); $('#hazardsBody').on('click', '.delete-hazard', function () { - if (!confirm('@localizer["DeleteHazardConfirm"]')) return; + if (!confirm('@JsEncoder.Encode(localizer["DeleteHazardConfirm"])')) return; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Contacts/DeleteHazard', type: 'POST', data: { contactPreplanHazardId: $(this).data('id'), __RequestVerificationToken: token }, success: function () { loadHazards(); }, - error: function () { toastr.error('@localizer["HazardSaveError"]'); } + error: function () { toastr.error('@JsEncoder.Encode(localizer["HazardSaveError"])'); } }); }); $('#saveHazardBtn').on('click', function () { var title = $('#hazardTitle').val().trim(); if (!title) { - toastr.error('@localizer["HazardTitleRequired"]'); + toastr.error('@JsEncoder.Encode(localizer["HazardTitleRequired"])'); return; } var payload = { @@ -456,10 +456,10 @@ $('#hazardModal').modal('hide'); loadHazards(); } else { - toastr.error((response && response.error) || '@localizer["HazardSaveError"]'); + toastr.error((response && response.error) || '@JsEncoder.Encode(localizer["HazardSaveError"])'); } }, - error: function () { toastr.error('@localizer["HazardSaveError"]'); } + error: function () { toastr.error('@JsEncoder.Encode(localizer["HazardSaveError"])'); } }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/Compliance.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/Compliance.cshtml index f5ed02578..64fc14282 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contracts/Compliance.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/Compliance.cshtml @@ -46,7 +46,7 @@ @if (Model.CanManageContracts) { - @Html.AntiForgeryToken() + @Html.AntiForgeryToken() } diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml index 0ca46fd7a..8c36d74e0 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml @@ -51,7 +51,7 @@ } @if (c.Status != (int)Resgrid.Model.Invoicing.ServiceContractStatuses.Active) { - @Html.AntiForgeryToken() + @Html.AntiForgeryToken() } } diff --git a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml index 32222e388..eb89d3132 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CustomMaps/Layers.cshtml @@ -99,7 +99,7 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml index ae9ac4434..d5bdc53aa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/DispatchSettings.cshtml @@ -427,7 +427,7 @@ @Html.AntiForgeryToken() - + @@ -557,7 +557,7 @@ @Html.AntiForgeryToken() - + diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/Profile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/Profile.cshtml index e21d30d97..5f5284791 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/Profile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/Profile.cshtml @@ -128,7 +128,7 @@ @Html.AntiForgeryToken() - + } else @@ -154,7 +154,7 @@

@Model.PublicMastheadUrl

@Html.AntiForgeryToken() - + diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index 3f49d713e..9aef20a2f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -107,7 +107,7 @@ @if (canSubmit) { - + } }
@@ -172,11 +172,11 @@
@if (canApprove) { -
@Html.AntiForgeryToken() +
@Html.AntiForgeryToken() } @if (canVoid) { -
@Html.AntiForgeryToken() +
@Html.AntiForgeryToken() } @if (Model.CanActOnReport) { @@ -207,8 +207,8 @@ if (rows.length) { clone = rows[rows.length - 1].cloneNode(true); clone.querySelectorAll('input[type=text],input[type=number],input.entry-id,input[name$=".CertificationCode"]').forEach(function (i) { i.value = ''; i.removeAttribute('data-adp-field'); }); } else { clone = document.createElement('tr'); - clone.innerHTML = '
' + - '' + + clone.innerHTML = '' + + '' + '' + '' + '' + diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml index f6ad4374b..ffaf4a699 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -118,7 +118,7 @@ - + } @@ -158,7 +158,7 @@ { - + } @@ -217,7 +217,7 @@ var seat = p.UnitRoleId.HasValue ? Model.UnitRoles.Values.SelectMany(r => r).FirstOrDefault(r => r.UnitRoleId == p.UnitRoleId.Value)?.Name : null; - + } @@ -320,7 +320,7 @@ - + } @@ -369,7 +369,7 @@
{Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Type"].Value)}{Html.Encode(commonLocalizer["Category"].Value)}{Html.Encode(localizer["LastUpdated"].Value)}
" + c.GetName() + reviewBadge + "" + c.GetTypeName() + "" + c.GetCategoryName() + "" + timestamp + "
" + Html.Encode(c.GetName()) + reviewBadge + "" + Html.Encode(c.GetTypeName()) + "" + Html.Encode(c.GetCategoryName()) + "" + Html.Encode(timestamp) + "") @if (ClaimsAuthorizationHelper.CanViewContacts()) { - @Html.Raw($"{@localizer["ViewContact"]} ") + @Html.Raw($"{Html.Encode(localizer["ViewContact"].Value)} ") } @if (ClaimsAuthorizationHelper.CanEditContacts()) { - @Html.Raw($"{@commonLocalizer["Edit"]} ") + @Html.Raw($"{Html.Encode(commonLocalizer["Edit"].Value)} ") ; } @if (ClaimsAuthorizationHelper.CanDeleteContacts()) { - @Html.Raw($"{@commonLocalizer["Delete"]}") + @Html.Raw($"{Html.Encode(commonLocalizer["Delete"].Value)}") ; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml index 35d504308..7b58a465c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/Preplan.cshtml @@ -383,8 +383,8 @@ '' + esc(h.LocationDescription) + (h.GpsCoordinates ? '
' + esc(h.GpsCoordinates) + '' : '') + '
' + (h.ShouldAlert ? '' : '') + '' + - ' ' + - '' + + ' ' + + '' + '
@localizer["RegionEditor"] - @localizer["Delete"] + @localizer["Delete"]
@(u.UnitName ?? u.UnitId.ToString())@u.CallSign@activePeople.Count(p => p.DeploymentUnitId == u.DeploymentUnitId)@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
@(p.DisplayName ?? UserName(p.UserId))@UnitName(p.DeploymentUnitId)@seat@p.CertificationCode@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
@if (Model.CanManage && d.IsOpen) {
@Html.AntiForgeryToken()
}
@e.Amount.ToString("N2") @e.Currency @if (e.ReceiptAttachmentId.HasValue) { } @if (!string.IsNullOrWhiteSpace(e.DeploymentTimeReportId)) { var r = Model.TimeReports.FirstOrDefault(x => x.DeploymentTimeReportId == e.DeploymentTimeReportId); #@(r?.ReportNumber) }@if (Model.CanEditTime) {
@Html.AntiForgeryToken()
}
@if (Model.CanEditTime) {
@Html.AntiForgeryToken()
}
@contractorStrings["TotalBeforeTax"]@Model.Charges.TotalBeforeTax.ToString("N2") @Model.Charges.Currency
-
+ @Html.AntiForgeryToken() @@ -457,7 +457,7 @@ { @a.Name@localizer["Attachment" + (Resgrid.Model.Invoicing.DeploymentAttachmentTypes)a.AttachmentType]@a.FileName@((a.FileSize ?? 0) / 1024) KB@Local(a.AddedOn) · @UserName(a.AddedByUserId) - @if (Model.CanManage) { @Html.AntiForgeryToken()
} + @if (Model.CanManage) {
@Html.AntiForgeryToken()
} } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index f135f06a3..d631c47e8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -352,7 +352,7 @@ if (ungroupedUnits != null && ungroupedUnits.Any()) { - @Html.Raw("") + @Html.Raw("") ; count++; } @@ -366,12 +366,12 @@ { if (count == 0) { - @Html.Raw("") + @Html.Raw("") ; } else { - @Html.Raw("
  • " + Model.Groups[i].Name + "
  • ") + @Html.Raw("
  • " + Html.Encode(Model.Groups[i].Name) + "
  • ") ; } @@ -390,7 +390,7 @@ { @Html.Raw("
    ") ; - @Html.Raw("
    ") + @Html.Raw("
    " + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
    ") ; if (ungroupedUnits2 != null && ungroupedUnits2.Any()) @@ -414,7 +414,7 @@ } } - @Html.Raw("") + @Html.Raw("") ; } } @@ -441,7 +441,7 @@ ; } - @Html.Raw("
    " + Html.Encode(commonLocalizer["Name"].Value) + "" + Html.Encode(commonLocalizer["Type"].Value) + "" + Html.Encode(commonLocalizer["Status"].Value) + "
    " + u.Name + "" + u.Type + "" + stateText + "
    " + Html.Encode(u.Name) + "" + Html.Encode(u.Type) + "" + Html.Encode(stateText) + "
    ") + @Html.Raw("
    " + @commonLocalizer["Name"] + "" + @commonLocalizer["Type"] + "" + @commonLocalizer["Status"] + "
    ") ; if (groupUnits != null && groupUnits.Any()) @@ -465,7 +465,7 @@ } } - @Html.Raw("") + @Html.Raw("") ; } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml index 85a8f7a48..c8f9ab120 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml @@ -56,7 +56,7 @@ @if (Model.CanDelete) { + onsubmit="return confirm('@JsEncoder.Encode(localizer["DeleteDocumentWarning"])');"> @Html.AntiForgeryToken() @@ -57,7 +57,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml index c93ff5d61..437275e9f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Details.cshtml @@ -620,7 +620,7 @@
    @localizer["Void"]
    - + @Html.AntiForgeryToken()
    @@ -633,7 +633,7 @@ {
    - + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml index 83b47b0d5..993f39ee6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IndoorMaps/Index.cshtml @@ -50,7 +50,7 @@
    } diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml index 62d62e8a7..eef208ced 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml @@ -118,7 +118,7 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/ByUnit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/ByUnit.cshtml index d8bb96f1d..64273f491 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/ByUnit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/ByUnit.cshtml @@ -45,15 +45,15 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/History.cshtml index 3e74fc94c..44125b545 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/History.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/History.cshtml @@ -46,15 +46,15 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Index.cshtml index 802e2d11f..d6a9ff3aa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Index.cshtml @@ -56,10 +56,10 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/ManageTypes.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/ManageTypes.cshtml index 6e2d1fcce..25a943f62 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/ManageTypes.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/ManageTypes.cshtml @@ -50,11 +50,11 @@ { diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml index 4665f5805..8fd69961a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml @@ -190,19 +190,20 @@ preview: '@Url.Action("PreviewCallLines", "Invoicing", new { area = "User" })' }; var text = { - remove: '@localizer["Remove"]', - add: '@localizer["Add"]', - added: '@localizer["CallAdded"]', - noCalls: '@localizer["NoCallsForCustomer"]', - loadFailed: '@localizer["LoadFailed"]', - saveFailed: '@localizer["SaveFailed"]', - invoiced: '@localizer["AlreadyInvoiced"]' + remove: '@JsEncoder.Encode(localizer["Remove"])', + add: '@JsEncoder.Encode(localizer["Add"])', + added: '@JsEncoder.Encode(localizer["CallAdded"])', + noCalls: '@JsEncoder.Encode(localizer["NoCallsForCustomer"])', + loadFailed: '@JsEncoder.Encode(localizer["LoadFailed"])', + saveFailed: '@JsEncoder.Encode(localizer["SaveFailed"])', + invoiced: '@JsEncoder.Encode(localizer["AlreadyInvoiced"])' }; // Longer, translated sentences travel as JSON so apostrophes and quotes survive into the badges' titles. var timeText = JSON.parse(document.getElementById('timeSourceText').textContent); function money(v) { return (Math.round((Number(v) || 0) * 100) / 100).toFixed(2); } - function esc(s) { return $('
    ').text(s == null ? '' : s).html(); } + // Used in attribute values as well as element text, so quotes are escaped too. + function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // Where a generated line's on-scene time came from (InvoiceLineTimeSources): 1 unit status, 2 auto-linked, 3 inferred, 4 call window. function timeSourceBadge(source) { @@ -224,7 +225,7 @@ '
    ' + '' + '' + - '' + + '' + ''); }); $('#totSubTotal').text(money(subTotal)); @@ -269,11 +270,11 @@ $('#callModal').modal('show'); $.getJSON(urls.calls, { id: invoiceId }).done(function (calls) { var body = $('#callsTable tbody').empty(); - if (!calls.length) { body.append(''); return; } + if (!calls.length) { body.append(''); return; } calls.forEach(function (c) { body.append('' + - ''); + ''); }); }).fail(function () { toastr.error(text.loadFailed); }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml index 999bad797..0258eac98 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/EditRateCard.cshtml @@ -100,7 +100,7 @@ @Html.AntiForgeryToken() - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml index 565da1db2..356cb1808 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/RateCards.cshtml @@ -55,7 +55,7 @@ { @Html.AntiForgeryToken() - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml index 4206519d5..4588cba72 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Settings.cshtml @@ -162,7 +162,7 @@ { @Html.AntiForgeryToken() - + @if (!connection.IsUsable && op.AvailableInCluster) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml index cc0732afe..3ecc2383d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml @@ -40,7 +40,7 @@ { @Html.AntiForgeryToken() - + } @if (isOpen && write) @@ -335,7 +335,7 @@ var input = document.getElementById('payUrl'); if (!input) return; input.select(); - var copied = function () { toastr.success('@localizer["LinkCopied"]'); }; + var copied = function () { toastr.success('@JsEncoder.Encode(localizer["LinkCopied"])'); }; var fallback = function () { try { if (document.execCommand('copy')) copied(); } catch (e) { } }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(input.value).then(copied, fallback); diff --git a/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml index 5274e0c6c..67a68f53a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Logs/ViewLog.cshtml @@ -636,7 +636,7 @@ @if (Model.CanDelete) { + onclick="return confirm('@JsEncoder.Encode(localizer["DeleteLogConfirm"])');"> @localizer["DeleteLog"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml index f76f1df71..9a0c5a5a1 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/LiveRouting.cshtml @@ -91,7 +91,9 @@ var distKm = (routeData.distance / 1000).toFixed(1); var durationMin = Math.round(routeData.duration / 60); var dvDistance = document.getElementById('dvDistance'); - dvDistance.innerHTML = '@localizer["DistanceLabel"] ' + distKm + ' km
    @localizer["DurationLabel"] ' + durationMin + ' min'; + dvDistance.textContent = '@JsEncoder.Encode(localizer["DistanceLabel"]) ' + distKm + ' km'; + dvDistance.appendChild(document.createElement('br')); + dvDistance.appendChild(document.createTextNode('@JsEncoder.Encode(localizer["DurationLabel"]) ' + durationMin + ' min')); if (routeData.legs.length > 0) { var steps = routeData.legs[0].steps; diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml index de088c23a..4d680c04e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/StationRouting.cshtml @@ -86,7 +86,9 @@ var distKm = (routeData.distance / 1000).toFixed(1); var durationMin = Math.round(routeData.duration / 60); var dvDistance = document.getElementById('dvDistance'); - dvDistance.innerHTML = '@localizer["DistanceLabel"] ' + distKm + ' km
    @localizer["DurationLabel"] ' + durationMin + ' min'; + dvDistance.textContent = '@JsEncoder.Encode(localizer["DistanceLabel"]) ' + distKm + ' km'; + dvDistance.appendChild(document.createElement('br')); + dvDistance.appendChild(document.createTextNode('@JsEncoder.Encode(localizer["DurationLabel"]) ' + durationMin + ' min')); if (routeData.legs.length > 0) { var steps = routeData.legs[0].steps; diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml index fcb9cff2d..3e40c40d3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Inbox.cshtml @@ -40,7 +40,7 @@ if (Model.Messages != null && Model.Messages.Any()) { @Html.Raw("
    " + Html.Encode(commonLocalizer["Name"].Value) + "" + Html.Encode(commonLocalizer["Type"].Value) + "" + Html.Encode(commonLocalizer["Status"].Value) + "
    " + u.Name + "" + u.Type + "" + stateText + "
    " + Html.Encode(u.Name) + "" + Html.Encode(u.Type) + "" + Html.Encode(stateText) + "
    @if (ClaimsAuthorizationHelper.IsUserDepartmentOrGroupAdmin(g.Group.DepartmentGroupId) && Model.States == null) { - @localizer["ResetGroupStandingBy"] + @localizer["ResetGroupStandingBy"] }
    @localizer["Floors"] @localizer["Edit"] - @localizer["Delete"] + @localizer["Delete"]
    ' + money(amount) + '
    ' + text.noCalls + '
    ' + esc(text.noCalls) + '
    ' + esc(c.Number || c.number) + '' + esc(c.Name || c.name) + '' + esc((c.LoggedOn || c.loggedOn || '').toString().substring(0, 16).replace('T', ' ')) + '' + ((c.AlreadyInvoiced || c.alreadyInvoiced) ? '' + text.invoiced + ' ' : '') + - '
    ' + ((c.AlreadyInvoiced || c.alreadyInvoiced) ? '' + esc(text.invoiced) + ' ' : '') + + '
    ") - @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") foreach (var message in Model.Messages) @@ -51,11 +51,11 @@ if (message.HasUserRead(Model.User.Id)) { - @Html.Raw($"") + @Html.Raw($"") } else { - @Html.Raw($"") + @Html.Raw($"") } @@ -65,11 +65,11 @@ } else { - @Html.Raw($"") + @Html.Raw($"") } - @Html.Raw($"") - @Html.Raw($"") + @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml b/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml index dd0d18aec..207c6d503 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Messages/Outbox.cshtml @@ -39,15 +39,15 @@ if (Model.Messages != null && Model.Messages.Any()) { @Html.Raw("
    {@localizer["Subject"]}{@localizer["SentBy"]}{@localizer["SentOn"]}
    {Html.Encode(localizer["Subject"].Value)}{Html.Encode(localizer["SentBy"].Value)}{Html.Encode(localizer["SentOn"].Value)}
    {message.Subject}{Html.Encode(message.Subject)}{message.Subject}{Html.Encode(message.Subject)}{(await UserHelper.GetFullNameForUser(message.SendingUserId))}{Html.Encode(await UserHelper.GetFullNameForUser(message.SendingUserId))}{message.SentOn.TimeConverterToString(Model.Department)}{@commonLocalizer["View"]} {@commonLocalizer["Delete"]}{Html.Encode(message.SentOn.TimeConverterToString(Model.Department))}{Html.Encode(commonLocalizer["View"].Value)} {Html.Encode(commonLocalizer["Delete"].Value)}
    ") - @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") foreach (var message in Model.Messages) { @Html.Raw($"") - @Html.Raw($"") - @Html.Raw($"") - @Html.Raw($"") + @Html.Raw($"") + @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") } diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/DeletePerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/DeletePerson.cshtml index 9b69c8578..a2a35bdcb 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/DeletePerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/DeletePerson.cshtml @@ -38,7 +38,7 @@
    - @localizer["DeleteAreYouSure"]@localizer["DeleteAreYouSure2"] + @localizer["DeleteAreYouSure"] @localizer["DeleteAreYouSure2"]

    @localizer["SpecialNote"] @localizer["SpecialNoteText"] diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml index 61787e945..4d56587d7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/Index.cshtml @@ -2,6 +2,7 @@ @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.PersonnelModel @inject IStringLocalizer localizer +@inject IStringLocalizer profileLocalizer @{ ViewBag.Title = "Resgrid | " + @localizer["PersonnelHeader"]; } @@ -66,7 +67,7 @@ { @Html.Raw("
    ") @Html.Raw("
    {@localizer["Subject"]}{@localizer["SentOn"]}
    {Html.Encode(localizer["Subject"].Value)}{Html.Encode(localizer["SentOn"].Value)}
    {message.Subject}{message.SentOn.TimeConverterToString(Model.Department)}{@commonLocalizer["View"]} {@commonLocalizer["Delete"]}{Html.Encode(message.Subject)}{Html.Encode(message.SentOn.TimeConverterToString(Model.Department))}{Html.Encode(commonLocalizer["View"].Value)} {Html.Encode(commonLocalizer["Delete"].Value)}
    ") - @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") foreach (var person in Model.Persons) @@ -74,11 +75,11 @@ var personStatus = Model.PersonnelStates.FirstOrDefault(x => x.CustomStateDetailId == person.StatusId); var personStaffing = Model.PersonnelStaffings.FirstOrDefault(x => x.CustomStateDetailId == person.StaffingId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; - var staffingText = "Unknown"; + var staffingText = commonLocalizer["Unknown"].Value; var staffingColor = "#000000"; var staffingTextColor = "#FFFFFF"; @@ -106,7 +107,7 @@ } else { - @Html.Raw($"

    {@localizer["NoPersonnelInDepartment"]}

    ") + @Html.Raw($"

    {Html.Encode(localizer["NoPersonnelInDepartment"].Value)}

    ") } } @@ -119,7 +120,7 @@ @Html.Raw("
    {@commonLocalizer["Name"]}{@commonLocalizer["Roles"]}{@commonLocalizer["Staffing"]}{@commonLocalizer["Status"]}{@localizer["State"]}{@localizer["SetStaffing"]}{@localizer["SetStatus"]}
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Roles"].Value)}{Html.Encode(commonLocalizer["Staffing"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(localizer["State"].Value)}{Html.Encode(localizer["SetStaffing"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    ") - @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") if (ungroupedPersonnel2 != null && ungroupedPersonnel2.Any()) @@ -129,11 +130,11 @@ var personStatus = Model.PersonnelStates.FirstOrDefault(x => x.CustomStateDetailId == person.StatusId); var personStaffing = Model.PersonnelStaffings.FirstOrDefault(x => x.CustomStateDetailId == person.StaffingId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; - var staffingText = "Unknown"; + var staffingText = commonLocalizer["Unknown"].Value; var staffingColor = "#000000"; var staffingTextColor = "#FFFFFF"; @@ -164,7 +165,7 @@ } else { - @Html.Raw($"

    {@localizer["NoUnGroupedPersonnel"]}

    ") + @Html.Raw($"

    {Html.Encode(localizer["NoUnGroupedPersonnel"].Value)}

    ") } } @@ -186,7 +187,7 @@ @Html.Raw($"
    {@commonLocalizer["Name"]}{@commonLocalizer["Roles"]}{@commonLocalizer["Staffing"]}{@commonLocalizer["Status"]}{@localizer["State"]}{@localizer["SetStaffing"]}{@localizer["SetStatus"]}
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Roles"].Value)}{Html.Encode(commonLocalizer["Staffing"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(localizer["State"].Value)}{Html.Encode(localizer["SetStaffing"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    ") - @Html.Raw($"") + @Html.Raw($"") @Html.Raw($"") if (groupPersons != null && groupPersons.Any()) @@ -196,11 +197,11 @@ var personStatus = Model.PersonnelStates.FirstOrDefault(x => x.CustomStateDetailId == person.StatusId); var personStaffing = Model.PersonnelStaffings.FirstOrDefault(x => x.CustomStateDetailId == person.StaffingId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; - var staffingText = "Unknown"; + var staffingText = commonLocalizer["Unknown"].Value; var staffingColor = "#000000"; var staffingTextColor = "#FFFFFF"; @@ -232,7 +233,7 @@ else { @Html.Raw("") } } @@ -473,37 +474,39 @@ @{ void PersonnelTableRowTemplate(PersonnelForListJson p, string stateTextColor, string stateColor, string stateText, string staffingTextColor, string staffingColor, string staffingText, int groupType) { + // Razor does not encode text built inside Html.Raw: person fields, custom staffing/status text and colors, + // and labels all go through Html.Encode, which also escapes apostrophes for the single-quoted attributes. @if (p.CanEditUser) { - @Html.Raw($"") - @Html.Raw($"") - @Html.Raw($"") + @Html.Raw($"") + @Html.Raw($"") + @Html.Raw($"") } else { - @Html.Raw($"") + @Html.Raw($"") @Html.Raw("") } @Html.Raw(" @@ -163,7 +163,7 @@ @if (canEdit) { - @Html.AntiForgeryToken() + @Html.AntiForgeryToken() } @@ -262,9 +262,10 @@ (function () { var bandTypes = @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(bandTypes.Select(b => new { v = (int)b, t = localizer["Band" + b].Value }))); function val(v) { return v === null || v === undefined ? '' : v; } + function esc(v) { return String(val(v)).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function bandRow(b) { b = b || {}; - var opts = bandTypes.map(function (t) { return ''; }).join(''); + var opts = bandTypes.map(function (t) { return ''; }).join(''); return '' + '' + '' + @@ -274,8 +275,8 @@ '' + '' + '' + - '' + - '' + + '' + + '' + ''; } function num(el) { var v = $(el).val(); return v === '' ? null : parseFloat(v); } @@ -296,9 +297,9 @@ $('#pf-apply').on('click', function () { var base = parseFloat($('#pf-base').val()); if (isNaN(base)) return; var rows = []; - var standby = parseFloat($('#pf-standby').val()); if (!isNaN(standby)) rows.push({ BandType: 0, Rate: standby, Label: '@localizer["BandStandby"]' }); + var standby = parseFloat($('#pf-standby').val()); if (!isNaN(standby)) rows.push({ BandType: 0, Rate: standby, Label: '@JsEncoder.Encode(localizer["BandStandby"])' }); var ot1m = parseFloat($('#pf-ot1m').val()), ot1h = parseFloat($('#pf-ot1h').val()), ot2m = parseFloat($('#pf-ot2m').val()), ot2h = parseFloat($('#pf-ot2h').val()); - rows.push({ BandType: 1, Rate: base, ThresholdStartHours: 0, ThresholdEndHours: isNaN(ot1h) ? null : ot1h, Label: '@localizer["BandDeployment"]' }); + rows.push({ BandType: 1, Rate: base, ThresholdStartHours: 0, ThresholdEndHours: isNaN(ot1h) ? null : ot1h, Label: '@JsEncoder.Encode(localizer["BandDeployment"])' }); if (!isNaN(ot1m) && !isNaN(ot1h)) rows.push({ BandType: 2, Rate: Math.round(base * ot1m * 100) / 100, ThresholdStartHours: ot1h, ThresholdEndHours: isNaN(ot2h) ? null : ot2h, Label: 'OT ×' + ot1m }); if (!isNaN(ot2m) && !isNaN(ot2h)) rows.push({ BandType: 3, Rate: Math.round(base * ot2m * 100) / 100, ThresholdStartHours: ot2h, Label: 'OT ×' + ot2m }); $('#bandTable tbody').html(rows.map(bandRow).join('')); diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml index 895d1273d..6d534eb30 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordCrr/Edit.cshtml @@ -43,7 +43,7 @@ @if (!Model.IsNew) { - + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml index dd040d862..7aa835690 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Edit.cshtml @@ -143,6 +143,6 @@ @if (!readOnly && Model.Version > 1 || !readOnly && Model.Aggregate?.Published == null) { - @Html.AntiForgeryToken() + @Html.AntiForgeryToken() } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml index bf51063ad..226a83329 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/History.cshtml @@ -21,7 +21,7 @@
    @if (!Model.Aggregate.Definition.IsRetired) { -
    + @Html.AntiForgeryToken() @@ -76,7 +76,7 @@ @await Html.PartialAsync("_JsonInputHelp", Resgrid.Web.Helpers.JsonInputHelp.For>("mappingJson", "[{\"FromFieldKey\":\"old_key\",\"ToFieldKey\":\"new_key\"}]", optional: true))
    - +
    } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml index e18b26c0e..ca68af27d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDefinitions/Impact.cshtml @@ -57,7 +57,7 @@ } @if (canPublish) { -
    + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml index ee5946b40..320d7d9d5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordHydrants/Details.cshtml @@ -53,7 +53,7 @@ @if (h.InService) { } -
    + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml index bc8420463..76c075a13 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInspections/Details.cshtml @@ -59,7 +59,7 @@ } @if (i.State == (int)RmsInspectionState.Scheduled || i.State == (int)RmsInspectionState.InProgress) { -
    @Html.AntiForgeryToken() + @Html.AntiForgeryToken()
    }
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml index 14b7a4dc9..11e15003c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Details.cshtml @@ -164,7 +164,7 @@
    @L["Actions"]
    @if (!c.IsClosed) { -
    + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml index 1a7786bb6..83b74e0ef 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Crosswalk.cshtml @@ -46,7 +46,7 @@

    @L["SwitchOwnershipIntro"]

    @if (s.CanSwitchToRecords) { - + @Html.AntiForgeryToken()
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml index 92dec267b..2176716a8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordOccupancies/Details.cshtml @@ -217,12 +217,12 @@ @if (Model.CanAdminister && o.Status != (int)RmsOccupancyStatus.Merged) {
    @L["Actions"]
    - + @Html.AntiForgeryToken() -
    + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Index.cshtml index d7fd7554a..0fbd1e2ee 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordSavedReports/Index.cshtml @@ -47,7 +47,7 @@ @if (Model.CanManage) { @localizer["Edit"] -
    @Html.AntiForgeryToken() +
    @Html.AntiForgeryToken() } diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Accountability.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Accountability.cshtml index 8fe2a6b3e..f0bf3b417 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Accountability.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Accountability.cshtml @@ -118,7 +118,7 @@ } @if (Model.CanRemind && row.OpenRecords.Count > 0) { -
    + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml index fbbe1b7e4..99595210a 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml @@ -281,7 +281,7 @@
    @localizer["Void"]
    - + @Html.AntiForgeryToken()
    @@ -318,7 +318,7 @@ @if (Model.CanVoid && RmsLifecycle.CanTransition((RmsLifecyclePreset)record.LifecyclePreset, Model.State, RmsRecordState.Cancelled)) { - + @Html.AntiForgeryToken() diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml index 3c286099a..b33a15f34 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/Readiness.cshtml @@ -73,8 +73,8 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml index 1ce3ec13c..e20f7213d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsCounts.cshtml @@ -22,7 +22,7 @@ if (chart != "none") {
    - + }
    {@commonLocalizer["Name"]}{@commonLocalizer["Roles"]}{@commonLocalizer["Staffing"]}{@commonLocalizer["Status"]}{@localizer["State"]}{@localizer["SetStaffing"]}{@localizer["SetStatus"]}
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Roles"].Value)}{Html.Encode(commonLocalizer["Staffing"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(localizer["State"].Value)}{Html.Encode(localizer["SetStaffing"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    {p.Name}{p.Roles}{staffingText}{stateText}{p.State}
    {Html.Encode(p.Name)}{Html.Encode(p.Roles)}{Html.Encode(staffingText)}{Html.Encode(stateText)}{Html.Encode(p.State)}
    {p.Name}{p.Roles}{staffingText}{stateText}{p.State}
    {Html.Encode(p.Name)}{Html.Encode(p.Roles)}{Html.Encode(staffingText)}{Html.Encode(stateText)}{Html.Encode(p.State)}") - @Html.Raw($"{@commonLocalizer["View"]} ") + @Html.Raw($"{Html.Encode(commonLocalizer["View"].Value)} ") @if (p.CanEditUser) { @Html.Raw("
    ") - @Html.Raw($"{@commonLocalizer["Edit"]}") + @Html.Raw($"{Html.Encode(commonLocalizer["Edit"].Value)}") @Html.Raw("") @Html.Raw("") @Html.Raw("
     ") @@ -511,7 +514,7 @@ @if (p.CanRemoveUser) { - @Html.Raw($"{@commonLocalizer["Delete"]}") + @Html.Raw($"{Html.Encode(commonLocalizer["Delete"].Value)}") } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml index 7e9098993..2954a1cb4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml @@ -96,7 +96,8 @@
    @if (!m.IsActive) { - @localizer["SetAsActive"] + @* Not data-confirm: jquery-ujs would ask only after this onclick had already started the switch. *@ + @localizer["SetAsActive"] } @if (!m.IsDefault) @@ -176,17 +177,4 @@ @section Scripts { - } diff --git a/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml index c06230f5c..d23a3dbe8 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RateSchedules/Edit.cshtml @@ -92,7 +92,7 @@ @if (!Model.IsNew && canEdit) { -
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    } @@ -116,7 +116,7 @@ @if (canEdit) { -
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    }
    @if (showHours) { }@if (showPercent) { } diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml index 3798845f3..bb551482b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsAnalytics/_AnalyticsTimeRows.cshtml @@ -19,7 +19,7 @@ if (chart != "none") {
    - + }
    @L["AnalyticsCount"]@L["AnalyticsHours"]@L["AnalyticsShare"]
    diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml index fd950e43a..5d3f9c63f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordsExportTemplates/Index.cshtml @@ -66,7 +66,7 @@ } diff --git a/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml index c3584f031..111b3833d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Routes/Index.cshtml @@ -74,7 +74,7 @@ @Html.AntiForgeryToken() - diff --git a/Web/Resgrid.Web/Areas/User/Views/Search/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Search/Index.cshtml new file mode 100644 index 000000000..1198273a0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Search/Index.cshtml @@ -0,0 +1,221 @@ +@using Resgrid.Model.Search +@using Resgrid.Web.Helpers +@model Resgrid.WebCore.Areas.User.Models.Search.SearchIndexView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["PageTitle"]; + string FamilyLabel(string type) => localizer["Family." + type].ResourceNotFound ? type : localizer["Family." + type].Value; + string BadgeLabel(string type) => localizer["Badge." + type].ResourceNotFound ? type : localizer["Badge." + type].Value; + var showPager = Model.Searched && (Model.Page > 1 || Model.HasMore); + var lastPage = Model.Total.HasValue ? Math.Max(1, (int)Math.Ceiling(Model.Total.Value / (double)Model.PageSize)) : (int?)null; +} + +
    +
    +

    @localizer["PageTitle"]

    + +
    + @if (Model.Searched && Model.Results.Count > 0) + { + + } +
    + +
    +
    +
    + @if (Model.Unavailable) + { +
    @localizer["SearchUnavailable"]
    + } + else + { +
    +
    +
    +
    + + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    @localizer["Tips"]
    +
    +
    + @if (Model.IndexBuilding) + { +
    @localizer["IndexBuilding"]
    + } + @if (Model.Degraded && !Model.IndexBuilding) + { +
    @localizer["IndexUnavailable"]
    + } + @if (Model.InvalidDate) + { +
    @localizer["InvalidDate"]
    + } + + @if (!Model.Searched) + { +

    @localizer["EnterQuery"]

    + } + else if (Model.Results.Count == 0) + { +

    @localizer["NoResults"]

    +

    @localizer["NoResultsHint"]

    + } + else + { +

    + @if (Model.Total.HasValue) + { + @string.Format(localizer["ResultsOfTotal"].Value, Model.FirstIndex, Model.LastIndex, Model.Total.Value) + } + else + { + @string.Format(localizer["ResultsRange"].Value, Model.FirstIndex, Model.LastIndex) + } +

    + @if (!Model.Total.HasValue && Model.HasMore) + { +

    @localizer["TotalUnknown"]

    + } + +
    + @foreach (var row in Model.Results) + { +
    +

    + @BadgeLabel(row.EntityType) + @if (!string.IsNullOrWhiteSpace(row.Url)) + { + @SearchHighlighter.Highlight(row.Title, Model.HighlightTerms) + } + else + { + @SearchHighlighter.Highlight(row.Title, Model.HighlightTerms) + } +

    +
    + @if (!string.IsNullOrWhiteSpace(row.Number)) + { + #@row.Number + } + @if (!string.IsNullOrWhiteSpace(row.OccurredOn)) + { + @row.OccurredOn + } + @if (!string.IsNullOrWhiteSpace(row.Status)) + { + @row.Status + } + @if (!string.IsNullOrWhiteSpace(row.Category) && row.Category != row.Status) + { + @row.Category + } +
    + @if (!string.IsNullOrWhiteSpace(row.Summary)) + { +
    @SearchHighlighter.Highlight(row.Summary, Model.HighlightTerms)
    + } + @if (!string.IsNullOrWhiteSpace(row.Snippet) && row.Snippet != row.Summary) + { +
    @SearchHighlighter.Highlight(row.Snippet, Model.HighlightTerms)
    + } +
    + } +
    + + @if (showPager) + { + + } + } +
    +
    + } +
    +
    +
    + +@section Scripts { + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml index d5e444f5b..53aea38de 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Audits.cshtml @@ -1,23 +1,24 @@ @using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Units.UnitsIndexView +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Audit Logs"; + ViewBag.Title = "Resgrid | " + localizer["AuditLogsButton"]; }
    -

    Audit Logs

    +

    @localizer["AuditLogsButton"]

    @@ -27,22 +28,21 @@
    -
    Audit Logs
    +
    @localizer["AuditLogsButton"]
    - +

    - Search by user name, user or audit ID, email address, date/time, or audit type. - Search and sorting apply within the selected audit type. + @localizer["AuditLogsSearchHelp"]

    @@ -56,6 +56,52 @@
    @section Scripts { - + @{ + var jsonSettings = new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml }; + var auditLogStrings = new + { + columns = new + { + timestamp = localizer["AuditLogsColumnTimestamp"].Value, + type = localizer["AuditLogsColumnType"].Value, + loggedBy = localizer["AuditLogsColumnLoggedBy"].Value, + result = localizer["AuditLogsColumnResult"].Value, + message = localizer["AuditLogsColumnMessage"].Value, + searchTerms = localizer["AuditLogsColumnSearchTerms"].Value, + actions = localizer["AuditLogsColumnActions"].Value + }, + successful = localizer["AuditLogsResultSuccessful"].Value, + failed = localizer["AuditLogsResultFailed"].Value, + view = localizer["AuditLogsViewButton"].Value, + // Passed straight to DataTables as its "language" option. + table = new + { + search = localizer["AuditLogsSearchLabel"].Value, + searchPlaceholder = localizer["AuditLogsSearchPlaceholder"].Value, + info = localizer["AuditLogsTableInfo"].Value, + infoEmpty = localizer["AuditLogsTableInfoEmpty"].Value, + infoFiltered = localizer["AuditLogsTableInfoFiltered"].Value, + lengthMenu = localizer["AuditLogsTableLengthMenu"].Value, + loadingRecords = localizer["AuditLogsTableLoading"].Value, + emptyTable = localizer["AuditLogsTableEmpty"].Value, + zeroRecords = localizer["AuditLogsTableZeroRecords"].Value, + paginate = new + { + first = localizer["AuditLogsTableFirst"].Value, + last = localizer["AuditLogsTableLast"].Value, + next = localizer["AuditLogsTableNext"].Value, + previous = localizer["AuditLogsTablePrevious"].Value + }, + aria = new + { + sortAscending = localizer["AuditLogsTableSortAscending"].Value, + sortDescending = localizer["AuditLogsTableSortDescending"].Value + } + } + }; + } + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/FederatedMfa.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/FederatedMfa.cshtml new file mode 100644 index 000000000..010b057b1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Security/FederatedMfa.cshtml @@ -0,0 +1,173 @@ +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Security.FederatedMfaEditView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["FederatedMfaPageTitle"]; + var locked = !Model.CanChange; +} + +
    +
    +

    @localizer["FederatedMfaPageTitle"]

    + +
    + +
    + +
    + + @if (TempData["FederatedMfaError"] is string federatedMfaError) + { +
    @federatedMfaError
    + } + + @if (TempData["FederatedMfaSuccess"] != null) + { +
    + + @TempData["FederatedMfaSuccess"] +
    + } + +
    +
    +

    @localizer["FederatedMfaIntro"]

    + @if (!Model.ProviderStepUpAvailable) + { + @localizer["SecurityPolicyMethodNotYetAvailable"] + } +
    +
    + + @if (!Model.HasActiveSsoConfig) + { +
    + @localizer["FederatedMfaNoSso"] + @localizer["SecurityPolicyConfigureSsoLink"] +
    + } + else + { +
    + @if (!Model.HasMapping) + { +
    @localizer["FederatedMfaStatusNone"]
    + } + else if (Model.Effective) + { +
    @string.Format(localizer["FederatedMfaStatusEffective"].Value, Model.MappingVersion, + Model.TestedOnUtc?.ToString("yyyy-MM-dd HH:mm") + " UTC")
    + } + else + { +
    @string.Format(localizer["FederatedMfaStatusUntested"].Value, Model.MappingVersion)
    + } +
    + + @if (locked) + { +
    @localizer["SecurityPolicyMfaMethodsManagingMemberOnly"]
    + } + +
    + @Html.AntiForgeryToken() +
    + +
    +
    +
    +
    @localizer["FederatedMfaRequestHeader"]
    +
    + @if (Model.IsOidc) + { +
    + + +
    +
    + + +
    + } + else + { +
    + + +
    + } +
    +
    +
    +
    +
    +
    @localizer["FederatedMfaAcceptHeader"]
    +
    +

    @localizer["FederatedMfaValuesNote"]

    + @if (Model.IsOidc) + { +
    + + +
    +
    + + +
    +
    + + +
    + } + else + { +
    + + +
    + } +
    +
    +
    +
    + + @if (!locked) + { +
    + + @if (Model.HasMapping) + { + + + } + @localizer["SecurityPolicyCancelButton"] +
    + } + + + @if (!locked && Model.HasMapping) + { +

    @localizer["FederatedMfaTestNote"]

    +
    + @Html.AntiForgeryToken() + + + + } + } +
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index a9f72ecbf..7f220afea 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -634,12 +634,12 @@ type: 'POST', headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() } }).done(function (data) { - toastr.success('@localizer["Require2FASettingSaved"]'); + toastr.success('@JsEncoder.Encode(localizer["Require2FASettingSaved"])'); }).fail(function (xhr) { if (xhr.status === 412) { - toastr.error('@localizer["Require2FACannotEnableNoCurrentUser2FA"]'); + toastr.error('@JsEncoder.Encode(localizer["Require2FACannotEnableNoCurrentUser2FA"])'); } else { - toastr.error('@localizer["Require2FASettingFailed"]'); + toastr.error('@JsEncoder.Encode(localizer["Require2FASettingFailed"])'); } // Reset the dropdown to its previous saved value $(this).val('@Model.Require2FAForAdmins'); diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/ScimSetup.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/ScimSetup.cshtml index 3de0d6a4c..cd15a28fa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/ScimSetup.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/ScimSetup.cshtml @@ -87,7 +87,7 @@
    @Html.AntiForgeryToken() @@ -352,7 +352,7 @@ var el = document.getElementById(fieldId); el.select(); document.execCommand('copy'); - toastr.success('@localizer["SsoCopiedToClipboard"]'); + toastr.success('@JsEncoder.Encode(localizer["SsoCopiedToClipboard"])'); } @if (!string.IsNullOrWhiteSpace(Model.NewScimBearerToken)) diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/SecurityPolicy.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/SecurityPolicy.cshtml index 1b0ec8c68..054146c50 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/SecurityPolicy.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/SecurityPolicy.cshtml @@ -174,6 +174,157 @@
    + +
    +
    +
    +
    @localizer["SecurityPolicyMfaMethodsHeader"]
    +
    +

    @localizer["SecurityPolicyMfaMethodsIntro"]

    + @if (!Model.CanChangeMethodSwitches) + { +
    @localizer["SecurityPolicyMfaMethodsManagingMemberOnly"]
    + } + @if (!Model.PasskeysAvailable) + { +
    @localizer["SecurityPolicyMfaMethodsNotYetAvailable"]
    + } +
    +
    + + @localizer["SecurityPolicyAllowPasskeysForLoginMfaNote"] +
    +
    +
    +
    + + @localizer["SecurityPolicyAllowPasskeysForAdpNote"] +
    +
    +
    +
    + + @localizer["SecurityPolicyAllowResponderApprovalNote"] +
    +
    +
    +
    + + @localizer["SecurityPolicyAllowFederatedMfaForLoginMfaNote"] +
    +
    +
    +
    + + @localizer["SecurityPolicyAllowFederatedMfaForAdpNote"] +
    +
    + @if (Model.HasActiveSsoConfig) + { +

    @localizer["FederatedMfaLink"]

    + } +
    +
    + + @localizer["SecurityPolicyAcceptRecentLoginMfaForAdpNote"] +
    +
    +
    +
    + + @localizer["SecurityPolicyAcceptRecentUnlockMfaForAdpNote"] +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    @localizer["SecurityPolicySharedDevicesHeader"]
    +
    +

    @localizer["SecurityPolicySharedDevicesIntro"]

    + @if (!Model.CanChangeMethodSwitches) + { +
    @localizer["SecurityPolicySharedDevicesManagingMemberOnly"]
    + } + @if (!Model.SharedDeviceModeAvailable) + { +
    @localizer["SecurityPolicySharedDevicesNotYetAvailable"]
    + } +
    + + + @string.Format(localizer["SecurityPolicySharedIdleLockNote"].Value, Model.MaxSharedIdleLockMinutes) + +
    +
    + + + @string.Format(localizer["SecurityPolicySharedShiftNote"].Value, Model.MaxSharedShiftHours) + +
    +
    + +
    + + + +
    + @localizer["SecurityPolicySharedRequiredNote"] +
    +
    +
    +
    +
    +
    +
    + +
    + + + + +
    + @localizer["SsoEditOidcBrokerRedirectNote"] +
    + +
    + + @foreach (var app in Model.OidcAppRedirectUris) + { + var fieldId = app.Web ? $"oidcAppRedirect-{app.Name}-web" : $"oidcAppRedirect-{app.Name}"; +
    + @(app.Web ? localizer["SsoEditOidcAppRedirectWebLabel", app.DisplayName].Value : app.DisplayName) + + + + +
    + } + @localizer["SsoEditOidcAppRedirectNote"] +
    +
    +
    + + @Html.TextBoxFor(m => m.IdpSsoUrl, new { @class = "form-control", placeholder = "https://idp.example.com/saml2/sso" }) + @localizer["SsoEditIdpSsoUrlNote"] + +
    +
    @@ -52,7 +53,7 @@
    -
    Audit Log
    +
    @localizer["AuditLogViewPanelTitle"]
    @@ -60,13 +61,13 @@
    -
    Audit Log ID:
    +
    @localizer["AuditLogViewAuditLogId"]
    @Model.AuditLog.AuditLogId
    -
    Department ID:
    +
    @localizer["AuditLogViewDepartmentId"]
    @Model.AuditLog.DepartmentId
    @@ -74,13 +75,13 @@
    -
    Audit Type:
    +
    @localizer["AuditLogViewAuditType"]
    @Model.Type.ToString()
    -
    Log Type ID:
    +
    @localizer["AuditLogViewLogTypeId"]
    @Model.AuditLog.LogType
    @@ -88,21 +89,21 @@
    -
    Type Description:
    +
    @localizer["AuditLogViewTypeDescription"]
    @DisplayValue(Model.TypeName)
    -
    Result:
    +
    @localizer["AuditLogViewResult"]
    @if (Model.AuditLog.Successful) { - Successful + @localizer["AuditLogsResultSuccessful"] } else { - Failed + @localizer["AuditLogsResultFailed"] }
    @@ -111,13 +112,13 @@
    -
    Logged By:
    +
    @localizer["AuditLogViewLoggedBy"]
    @loggedBy
    -
    User ID:
    +
    @localizer["AuditLogViewUserId"]
    @DisplayValue(Model.AuditLog.UserId)
    @@ -125,13 +126,13 @@
    -
    Logged On (Local):
    -
    @(Model.AuditLog.LoggedOn.HasValue ? Model.AuditLog.LoggedOn.Value.TimeConverterToString(Model.Department) : "Unknown")
    +
    @localizer["AuditLogViewLoggedOnLocal"]
    +
    @(Model.AuditLog.LoggedOn.HasValue ? Model.AuditLog.LoggedOn.Value.TimeConverterToString(Model.Department) : localizer["AuditLogsUnknownTime"].Value)
    -
    Logged On (UTC):
    +
    @localizer["AuditLogViewLoggedOnUtc"]
    @if (Model.AuditLog.LoggedOn.HasValue) { @@ -139,7 +140,7 @@ } else { - @:Unknown + @localizer["AuditLogsUnknownTime"] }
    @@ -148,13 +149,13 @@
    -
    IP Address:
    +
    @localizer["AuditLogViewIpAddress"]
    @DisplayValue(Model.AuditLog.IpAddress)
    -
    Server Name:
    +
    @localizer["AuditLogViewServerName"]
    @DisplayValue(Model.AuditLog.ServerName)
    @@ -162,13 +163,13 @@
    -
    Object ID:
    +
    @localizer["AuditLogViewObjectId"]
    @DisplayValue(Model.AuditLog.ObjectId)
    -
    Object Department ID:
    +
    @localizer["AuditLogViewObjectDepartmentId"]
    @Model.AuditLog.ObjectDepartmentId
    @@ -176,7 +177,7 @@
    -
    User Agent:
    +
    @localizer["AuditLogViewUserAgent"]
    @DisplayValue(Model.AuditLog.UserAgent)
    @@ -184,7 +185,7 @@
    -
    Message:
    +
    @localizer["AuditLogViewMessage"]
    @DisplayValue(Model.AuditLog.Message)
    @@ -192,7 +193,7 @@
    -
    Data:
    +
    @localizer["AuditLogViewData"]
    @DisplayValue(Model.AuditLog.Data)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml index 2b32b8ec1..65fc79de5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml @@ -1,5 +1,7 @@ @using System.Collections.Generic @model Resgrid.Web.Areas.User.Models.AdpRevealView +@inject Resgrid.Model.Services.ISsoBrokerService ssoBroker +@inject Resgrid.Model.Services.ISsoReturnTargetRegistry ssoReturnTargets @* Step-up modal + reveal module wiring (plan 7.2). Render inside the host view's Scripts section, paired with _AdpRevealBanner in the body. The grant issued by VerifyStepUp lives in @@ -30,6 +32,17 @@
    + @* Other ways to verify, shown only when the server lists them for this user and department (passkey plan section 7.5). *@ +
    + + + +
    +
    @Html.AntiForgeryToken() + + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_SharedSessionHead.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_SharedSessionHead.cshtml new file mode 100644 index 000000000..6b0786344 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_SharedSessionHead.cshtml @@ -0,0 +1,9 @@ +@{ + // Shared workstation sessions only (passkey plan section 12.5.4): a page brought back from history stays hidden until the + // server confirms the session is not locked, so the back button never shows a locked operator's work. + var sharedSession = Resgrid.Web.Helpers.WebSharedSession.SessionOf(Context); +} +@if (sharedSession?.SharedMode == true) +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml index 537d51bc3..b9c525d7b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml @@ -1,6 +1,7 @@ @using Resgrid.Framework @inject Resgrid.Model.Services.IFeatureToggleService featureToggleService @inject Microsoft.Extensions.Localization.IStringLocalizer aaLocalizer +@inject Microsoft.Extensions.Localization.IStringLocalizer searchLocalizer @{ // Admin Assist opens on its setup tabs (Admin.Setup), or on its Admin AI tab once Admin.Assist and Ai.AdminAssist are on. var adminAssistVisible = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && @@ -17,7 +18,7 @@
    diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Enable2FA.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Enable2FA.cshtml index cab9e0eac..a8fa95197 100644 --- a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Enable2FA.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Enable2FA.cshtml @@ -21,7 +21,7 @@
    -
    @localizer["SetupHeader"]
    +
    @(Model.IsReplacement ? localizer["ReplaceHeader"] : localizer["SetupHeader"])
    @if (enforced) @@ -32,6 +32,10 @@
    } + @if (Model.IsReplacement) + { +
    @localizer["ReplaceInstructions"]
    + }

    @localizer["SetupInstructions"]

    1. @@ -51,7 +55,7 @@
    2. @localizer["SetupStep3"]
    -
    + @Html.AntiForgeryToken()
    @@ -66,7 +70,7 @@
    - + @commonLocalizer["Cancel"]
    diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Index.cshtml index f5bfe6f3d..f754903ac 100644 --- a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Index.cshtml @@ -53,6 +53,7 @@ @Html.AntiForgeryToken() + @localizer["ReplaceAuthenticatorButton"] @localizer["Disable2FAButton"] } else @@ -65,7 +66,42 @@ }
    + + @if (Model.PasskeyStatus != null) + { +
    + @(Model.PasskeyStatus == "added" ? localizer["PasskeyAdded"] : Model.PasskeyStatus == "renamed" ? localizer["PasskeyRenamed"] : localizer["PasskeyRemoved"]) +
    + } +
    +@section Scripts { + + + +} + diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/ShowRecoveryCodes.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/ShowRecoveryCodes.cshtml index ef986ced3..1a2540648 100644 --- a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/ShowRecoveryCodes.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/ShowRecoveryCodes.cshtml @@ -23,6 +23,10 @@
    @localizer["SaveRecoveryCodesHeader"]
    + @if (Model.SignInAgainRequired) + { +
    @localizer["SignInAgainNotice"]
    + }
    @localizer["SaveRecoveryCodesWarning"]
    @@ -38,7 +42,14 @@
    - @localizer["DoneButton"] + @if (Model.SignInAgainRequired) + { + @localizer["SignInAgainButton"] + } + else + { + @localizer["DoneButton"] + }
    diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml index 41ba4c00e..7a1d0df96 100644 --- a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/Verify2FA.cshtml @@ -2,6 +2,7 @@ @inject IStringLocalizer localizer @{ ViewBag.Title = "Resgrid | " + localizer["VerifyPageTitle"]; + string Js(string key) => Newtonsoft.Json.JsonConvert.SerializeObject(localizer[key].Value); }
    @@ -23,10 +24,16 @@ @localizer["VerifyIdentityDescription"]
    + @if (TempData["StepUpMessage"] is string stepUpMessage) + { +
    @stepUpMessage
    + } -
    + @Html.AntiForgeryToken() + +
    @@ -42,9 +49,37 @@
    + @if (Model.PasskeyAvailable) + { + + } + @if (Model.ApprovalAvailable) + { + + } + @if (Model.FederatedAvailable) + { + + }
    + + + @if (Model.FederatedAvailable) + { +
    + @Html.AntiForgeryToken() + + + + + }
    @@ -53,4 +88,34 @@ @section Scripts { + @if (Model.PasskeyAvailable || Model.ApprovalAvailable) + { + + + + } } diff --git a/Web/Resgrid.Web/Areas/User/Views/TwoFactor/_Passkeys.cshtml b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/_Passkeys.cshtml new file mode 100644 index 000000000..ea4dcf8c1 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/TwoFactor/_Passkeys.cshtml @@ -0,0 +1,77 @@ +@model Resgrid.Web.Areas.User.Models.TwoFactor.TwoFactorIndexViewModel +@inject IStringLocalizer localizer +@* + Passkeys on the Web account security page (passkey plan sections 6.1 and 6.5): the user's passkeys in every app, + with rename and remove, and adding one for the web. The page script only runs the browser's ceremony; the server + checks every rule. Values are rendered with Razor encoding; the script inserts text only. +*@ +
    +
    +
    @localizer["PasskeysHeader"]
    +
    +
    +

    @localizer["PasskeysDescription"]

    + + + + @if (Model.Passkeys.Count == 0) + { +

    @localizer["PasskeysNone"]

    + } + else + { +
    @When(t.LastRunOn) @localizer["ExportRuns"] -
    @Html.AntiForgeryToken()
    +
    @Html.AntiForgeryToken()
    + + + + + + + + + + + @foreach (var passkey in Model.Passkeys) + { + + + + + + + + } + +
    @localizer["PasskeyNameLabel"]@localizer["PasskeyAppLabel"]@localizer["PasskeyCreatedLabel"]@localizer["PasskeyLastUsedLabel"]
    + @passkey.DisplayName + @if (passkey.CreatedOnSharedInstallation) + { +
    @localizer["PasskeySharedWarning"] + } +
    @localizer[passkey.AppLabelKey]@passkey.CreatedOn.ToString("d")@(passkey.LastUsedOn.HasValue ? passkey.LastUsedOn.Value.ToString("g") : localizer["PasskeyNeverUsed"].Value) + + +
    + } + + @if (!Model.Is2FAEnabled) + { +

    @localizer["PasskeysNeedAuthenticator"]

    + } + else if (!Model.WebPasskeyRegistrationAvailable) + { +

    @localizer["PasskeysUnavailable"]

    + } + else + { +
    +
    + + +
    + +
    + } +
    +
    +
    @Html.AntiForgeryToken()
    diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml index 5c178fb14..d300b72ab 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Credential.cshtml @@ -34,7 +34,7 @@
    @localizer["Protocol"]
    @(Model.Device.ProtocolKey ?? "—")
    @localizer["Transport"]
    -
    @((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType)
    +
    @UnitTrackingDisplayHelper.GetLocalizedTransport((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType, localizer, commonLocalizer)
    diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml index 48f4edfca..0ecbb8056 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Details.cshtml @@ -45,13 +45,13 @@
    @localizer["TrackingStatus"]
    -
    @commonLocalizer["Status"]
    @Model.Status
    +
    @commonLocalizer["Status"]
    @UnitTrackingDisplayHelper.GetLocalizedDeviceStatus(Model.Status, localizer)
    @localizer["TrackingProfile"]
    @Model.Device.ModelKey
    -
    @localizer["CertificationStatus"]
    @(Model.Profile?.CertificationStatus.ToString() ?? "—")
    +
    @localizer["CertificationStatus"]
    @(Model.Profile == null ? "—" : UnitTrackingDisplayHelper.GetLocalizedCertificationStatus(Model.Profile.CertificationStatus, localizer, commonLocalizer))
    @localizer["DeviceIdentifier"]
    @(Model.DisplayIdentifier ?? "—")
    @localizer["FirmwareVersion"]
    @(Model.Device.FirmwareVersion ?? "—")
    @localizer["Protocol"]
    @(Model.Device.ProtocolKey ?? "—")
    -
    @localizer["Transport"]
    @((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType)
    +
    @localizer["Transport"]
    @UnitTrackingDisplayHelper.GetLocalizedTransport((Resgrid.Model.UnitTrackingTransportType)Model.Device.TransportType, localizer, commonLocalizer)
    @localizer["LastSeen"]
    @(Model.Device.LastSeenOn?.ToString("g") ?? localizer["Never"])
    @localizer["LastValidFix"]
    @(Model.Device.LastPositionOn?.ToString("g") ?? localizer["Never"])
    @localizer["LastReceived"]
    @(Model.Device.LastReceivedOn?.ToString("g") ?? localizer["Never"])
    @@ -88,7 +88,7 @@ @foreach (var credential in Model.Credentials) { - @((Resgrid.Model.UnitTrackingAuthMode)credential.AuthMode) + @UnitTrackingDisplayHelper.GetLocalizedAuthMode((Resgrid.Model.UnitTrackingAuthMode)credential.AuthMode, localizer, commonLocalizer) @credential.KeyPrefix… @(credential.ExpiresOn?.ToString("g") ?? "—") @(credential.LastUsedOn?.ToString("g") ?? localizer["Never"]) @@ -139,7 +139,7 @@
    diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml index ee827349e..fe7709ff5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/Index.cshtml @@ -63,7 +63,7 @@ @(item.Device.DisplayName ?? item.Device.UnitTrackingDeviceId) @item.Device.ModelKey @(item.DisplayIdentifier ?? "—") - @item.Status + @UnitTrackingDisplayHelper.GetLocalizedDeviceStatus(item.Status, localizer) @(item.Device.LastSeenOn?.ToString("g") ?? localizer["Never"]) @(item.Device.LastPositionOn?.ToString("g") ?? localizer["Never"]) diff --git a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml index 5e9bb1f55..b7c84a5b2 100644 --- a/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/UnitTracking/_Editor.cshtml @@ -21,7 +21,7 @@ data-retry="@profile.RetryExpectation" data-certification="@profile.CertificationStatus" data-identifier-required="@profile.IdentifierRequired.ToString().ToLowerInvariant()"> - @profile.ManufacturerName — @profile.Model (@profile.CertificationStatus) + @profile.ManufacturerName — @profile.Model (@UnitTrackingDisplayHelper.GetLocalizedCertificationStatus(profile.CertificationStatus, localizer, commonLocalizer)) } diff --git a/Web/Resgrid.Web/Areas/User/Views/Units/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Units/Index.cshtml index abc08e816..0ef164732 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Units/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Units/Index.cshtml @@ -132,12 +132,12 @@ if (Model.Units != null && Model.Units.Any()) { @Html.Raw("
    ") - @Html.Raw($"
    ") + @Html.Raw($"
    {@commonLocalizer["Name"]}{@commonLocalizer["Type"]}{@commonLocalizer["Status"]}{@commonLocalizer["Timestamp"]}{@localizer["SetStatus"]}
    ") foreach (var u in Model.Units) { var unitStatus = Model.States.FirstOrDefault(x => x.UnitId == u.UnitId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; @@ -171,7 +171,7 @@ } else { - @Html.Raw($"

    {@localizer["NoUnitsInDepartment"]}

    ") + @Html.Raw($"

    {Html.Encode(localizer["NoUnitsInDepartment"].Value)}

    ") } } @@ -183,7 +183,7 @@ { @Html.Raw("
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Type"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(commonLocalizer["Timestamp"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    ") + @Html.Raw($"
    {@commonLocalizer["Name"]}{@commonLocalizer["Type"]}{@commonLocalizer["Status"]}{@commonLocalizer["Timestamp"]}{@localizer["SetStatus"]}
    ") if (ungroupedUnits2 != null && ungroupedUnits2.Any()) @@ -191,7 +191,7 @@ foreach (var u in ungroupedUnits2) { var unitStatus = Model.States.FirstOrDefault(x => x.UnitId == u.UnitId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; @@ -228,7 +228,7 @@ } else { - @Html.Raw($"

    {@localizer["NoUnGroupedUnits"]}

    ") + @Html.Raw($"

    {Html.Encode(localizer["NoUnGroupedUnits"].Value)}

    ") } } @@ -249,7 +249,7 @@ { @Html.Raw("
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Type"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(commonLocalizer["Timestamp"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    ") + @Html.Raw($"
    {@commonLocalizer["Name"]}{@commonLocalizer["Type"]}{@commonLocalizer["Status"]}{@commonLocalizer["Timestamp"]}{@localizer["SetStatus"]}
    ") ; if (groupUnits != null && groupUnits.Any()) @@ -257,7 +257,7 @@ foreach (var u in groupUnits) { var unitStatus = Model.States.FirstOrDefault(x => x.UnitId == u.UnitId); - var stateText = "Unknown"; + var stateText = commonLocalizer["Unknown"].Value; var stateColor = "#000000"; var stateTextColor = "#FFFFFF"; @@ -294,7 +294,7 @@ else { @Html.Raw("") } } @@ -429,6 +429,8 @@ @{ void UnitTableButtonTemplate(Unit u, int customState, string stateTextColor, string stateColor, string stateText, UnitState unitStatus) { + // Razor does not encode text built inside Html.Raw: unit fields, custom-status text and colors, and labels + // all go through Html.Encode, which also escapes apostrophes for the single-quoted attributes. var timestamp = DateTime.UtcNow.TimeConverter(Model.Department).FormatForDepartment(Model.Department); if (unitStatus != null) { @@ -437,22 +439,22 @@ if (Model.IsUserAdminOrGroupAdmin) { - @Html.Raw("") - @Html.Raw($"") + @Html.Raw("") + @Html.Raw($"") } else { - @Html.Raw("") + @Html.Raw("") @Html.Raw("") } @Html.Raw(""); for (var i = 0; i < group.Roles.length; i++) { addExistingGroupRole(group.Roles[i], resgrid.shifts.editshiftgroups.groupsCount); @@ -54,7 +54,7 @@ var resgrid; function addGroup() { resgrid.shifts.editshiftgroups.groupsCount++; var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeGroupLabel = i18n.removeGroup || 'Remove Group'; + var removeGroupLabel = escapeHtml(i18n.removeGroup || 'Remove Group'); $('#groups tbody').first().append(""); } editshiftgroups.addGroup = addGroup; @@ -63,9 +63,9 @@ var resgrid; // (roleSelection_{count}_{suffix} / groupRole_{count}_{suffix}); the server pairs them by it. var suffix = generate(4); var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeRoleLabel = i18n.removeRole || 'Remove Role'; - var removeRoleTitle = i18n.removeRoleFromGroup || 'Remove this role from the group'; - var roleCountMsg = i18n.roleCountRequired || 'Role count is required'; + var removeRoleLabel = escapeHtml(i18n.removeRole || 'Remove Role'); + var removeRoleTitle = escapeHtml(i18n.removeRoleFromGroup || 'Remove this role from the group'); + var roleCountMsg = escapeHtml(i18n.roleCountRequired || 'Role count is required'); $('#groupRolesTable_' + count + ' tbody').append(""); addGroupRoleField('groupRole_' + count + '_' + suffix); } @@ -73,9 +73,9 @@ var resgrid; function addExistingGroupRole(role, count) { var id = generate(4); var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeRoleLabel = i18n.removeRole || 'Remove Role'; - var removeRoleTitle = i18n.removeRoleFromGroup || 'Remove this role from the group'; - var roleCountMsg = i18n.roleCountRequired || 'Role count is required'; + var removeRoleLabel = escapeHtml(i18n.removeRole || 'Remove Role'); + var removeRoleTitle = escapeHtml(i18n.removeRoleFromGroup || 'Remove this role from the group'); + var roleCountMsg = escapeHtml(i18n.roleCountRequired || 'Role count is required'); $('#groupRolesTable_' + count + ' tbody').append(""); addGroupRoleField('groupRole_' + count + '_' + id); } @@ -131,10 +131,10 @@ var resgrid; editshiftgroups.generateExistingRoleDropdown = generateExistingRoleDropdown; function generateRolesTables(count) { var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var shiftRole = i18n.shiftRoleColumn || 'Shift Role'; - var rolesCount = i18n.rolesCountColumn || 'Roles Count'; - var addRoleLabel = i18n.addRoleToGroup || 'Add Role to Group'; - var addShiftRolesToGroupTitle = i18n.addShiftRolesToGroup || 'Add Shift Roles to Group'; + var shiftRole = escapeHtml(i18n.shiftRoleColumn || 'Shift Role'); + var rolesCount = escapeHtml(i18n.rolesCountColumn || 'Roles Count'); + var addRoleLabel = escapeHtml(i18n.addRoleToGroup || 'Add Role to Group'); + var addShiftRolesToGroupTitle = escapeHtml(i18n.addShiftRolesToGroup || 'Add Shift Roles to Group'); var rolesTable = '
    {Html.Encode(commonLocalizer["Name"].Value)}{Html.Encode(commonLocalizer["Type"].Value)}{Html.Encode(commonLocalizer["Status"].Value)}{Html.Encode(commonLocalizer["Timestamp"].Value)}{Html.Encode(localizer["SetStatus"].Value)}
    " + u.Name + "" + u.Type + "" + stateText + "" + timestamp + "
    " + Html.Encode(u.Name) + "" + Html.Encode(u.Type) + "" + Html.Encode(stateText) + "" + Html.Encode(timestamp) + "
    " + u.Name + "" + u.Type + "" + stateText + "" + timestamp + "
    " + Html.Encode(u.Name) + "" + Html.Encode(u.Type) + "" + Html.Encode(stateText) + "" + Html.Encode(timestamp) + "") - @Html.Raw($"{@localizer["ViewEvents"]} {@localizer["Logs"]} ") + @Html.Raw($"{Html.Encode(localizer["ViewEvents"].Value)} {Html.Encode(localizer["Logs"].Value)} ") @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) { - @Html.Raw($"{@commonLocalizer["Edit"]} {@commonLocalizer["Delete"]}") + @Html.Raw($"{Html.Encode(commonLocalizer["Edit"].Value)} {Html.Encode(commonLocalizer["Delete"].Value)}") } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml index 48cd3b84d..b3962d81d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/History.cshtml @@ -68,32 +68,32 @@ @section Scripts { + + + } + + diff --git a/Web/Resgrid.Web/Views/Account/LoginMfaSetup.cshtml b/Web/Resgrid.Web/Views/Account/LoginMfaSetup.cshtml new file mode 100644 index 000000000..fc05d91c7 --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/LoginMfaSetup.cshtml @@ -0,0 +1,61 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.LoginMfaSetupViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["LoginMfaSetupHeader"]; + Layout = null; +} + + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + +
    +
    +
    +
    + +
    +

    @localizer["LoginMfaSetupHeader"]

    +

    @localizer["LoginMfaSetupDescription"]

    + +
    +
      +
    1. @string.Format(localizer["SetupStep1"].Value, "Google Authenticator", "Microsoft Authenticator", "Authy")
    2. +
    3. @localizer["SetupStep2QR"]
    4. +
    +
    + QR code +

    @Model.SharedKey

    +
    +

    @localizer["SetupStep3"]

    +
    + @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) + { +
    + } +
    + + +
    + +

    @localizer["BackToSignInLink"]

    +
    +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + diff --git a/Web/Resgrid.Web/Views/Account/LoginWith2fa.cshtml b/Web/Resgrid.Web/Views/Account/LoginWith2fa.cshtml index 7b3fd205b..0bed75516 100644 --- a/Web/Resgrid.Web/Views/Account/LoginWith2fa.cshtml +++ b/Web/Resgrid.Web/Views/Account/LoginWith2fa.cshtml @@ -45,11 +45,15 @@ -
    -
    - + @if (!Resgrid.Web.Helpers.WebSharedSession.IsWorkstation(Context.Request)) + { + @* A shared workstation is never remembered (plan section 12.5.2). *@ +
    +
    + +
    -
    + } diff --git a/Web/Resgrid.Web/Views/Account/LoginWithRecoveryCode.cshtml b/Web/Resgrid.Web/Views/Account/LoginWithRecoveryCode.cshtml index 4872939b1..da02a94d9 100644 --- a/Web/Resgrid.Web/Views/Account/LoginWithRecoveryCode.cshtml +++ b/Web/Resgrid.Web/Views/Account/LoginWithRecoveryCode.cshtml @@ -46,9 +46,16 @@

    - - @localizer["UseAuthenticatorAppLink"] - + @if (ViewData["BackAction"] as string == "LoginMfa") + { + @localizer["UseAnotherMethodLink"] + } + else + { + + @localizer["UseAuthenticatorAppLink"] + + }

    diff --git a/Web/Resgrid.Web/Views/Account/LostFactor.cshtml b/Web/Resgrid.Web/Views/Account/LostFactor.cshtml new file mode 100644 index 000000000..ffbb09bd6 --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/LostFactor.cshtml @@ -0,0 +1,61 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.LostFactorViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["LostFactorHeader"]; + Layout = null; +} + + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + +
    +
    +
    +
    + +
    +

    @localizer["LostFactorHeader"]

    + +
    + @if (Model.CanRecover) + { +

    @localizer["LostFactorWithCodeDescription"]

    +
    + @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) + { +
    + } +
    + + +
    + +
    + } + else + { +
    @localizer["LostFactorSignInFirst"]
    + } + +

    @localizer["LostFactorNoCodeHeader"]

    +

    @localizer["LostFactorNoCodeDescription"]

    +

    @localizer["BackToSignInLink"]

    +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + diff --git a/Web/Resgrid.Web/Views/Account/Recovery.cshtml b/Web/Resgrid.Web/Views/Account/Recovery.cshtml new file mode 100644 index 000000000..56e48380e --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/Recovery.cshtml @@ -0,0 +1,78 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.FactorRecoveryViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["RecoveryHeader"]; + Layout = null; +} + + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + +
    +
    +
    +
    + +
    +

    @localizer["RecoveryHeader"]

    +

    @localizer["RecoveryDescription"]

    + +
    +
    + @Html.AntiForgeryToken() + @if (!ViewData.ModelState.IsValid) + { +
    + } +
      +
    1. @string.Format(localizer["SetupStep1"].Value, "Google Authenticator", "Microsoft Authenticator", "Authy")
    2. +
    3. @localizer["SetupStep2QR"]
    4. +
    +
    + QR code +

    @Model.SharedKey

    +
    +

    @localizer["SetupStep3"]

    +
    + + +
    + @if (Model.Passkeys.Count > 0) + { +
    + + @foreach (var passkey in Model.Passkeys) + { +
    + +
    + } +
    + } + +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + diff --git a/Web/Resgrid.Web/Views/Account/RecoveryComplete.cshtml b/Web/Resgrid.Web/Views/Account/RecoveryComplete.cshtml new file mode 100644 index 000000000..d5a02278a --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/RecoveryComplete.cshtml @@ -0,0 +1,48 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.RecoveryCompleteViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["RecoveryCompleteHeader"]; + Layout = null; +} + + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + +
    +
    +
    +
    + +
    +

    @localizer["RecoveryCompleteHeader"]

    +

    @localizer["RecoveryCompleteDescription"]

    + +
    +
    + @localizer["SaveRecoveryCodesHeader"] +

    @localizer["SaveRecoveryCodesWarning"]

    +
    +
    + @foreach (var code in Model.RecoveryCodes) + { +
    @code
    + } +
    + @localizer["RecoverySignInAgainButton"] +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + diff --git a/Web/Resgrid.Web/Views/Account/SsoLogOn.cshtml b/Web/Resgrid.Web/Views/Account/SsoLogOn.cshtml new file mode 100644 index 000000000..2b9d8216f --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/SsoLogOn.cshtml @@ -0,0 +1,60 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.SsoLogOnViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["SsoLogOnHeader"]; + Layout = null; +} + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + +
    +
    +
    +
    + +
    +

    @localizer["SsoLogOnHeader"]

    +

    @localizer["SsoLogOnDescription"]

    + +
    +
    + @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) + { +
    + } + +
    + + +
    +

    @localizer["SsoOr"]

    +
    + + +
    + + + +

    + @localizer["SsoUsePasswordLink"] +

    +
    +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + diff --git a/Web/Resgrid.Web/Views/Account/SsoReturnContinue.cshtml b/Web/Resgrid.Web/Views/Account/SsoReturnContinue.cshtml new file mode 100644 index 000000000..ca186e0c4 --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/SsoReturnContinue.cshtml @@ -0,0 +1,27 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.SsoReturnContinueViewModel +@inject IStringLocalizer localizer +@{ + Layout = null; + // The provider's return arrived cross-site, without this site's SameSite=Strict cookies. This page posts it on from this + // site, a same-site request that carries them; the post is matched to this browser's round trip before anything happens. +} + + + + + + + Resgrid + + +
    + + + +

    @localizer["SsoReturnContinuing"]

    + +
    + + + diff --git a/Web/Resgrid.Web/Views/Account/SsoReturnGrant.cshtml b/Web/Resgrid.Web/Views/Account/SsoReturnGrant.cshtml new file mode 100644 index 000000000..966e378b6 --- /dev/null +++ b/Web/Resgrid.Web/Views/Account/SsoReturnGrant.cshtml @@ -0,0 +1,30 @@ +@model Resgrid.Web.Models.AccountViewModels.SsoReturnGrantViewModel +@{ + Layout = null; + // The grant goes to the reveal dialog that opened this window, on this site only, and is never stored here. + var message = Newtonsoft.Json.JsonConvert.SerializeObject(Model.Error == null + ? (object)new { type = "resgrid-adp-grant", success = true, grantToken = Model.GrantToken, grantId = Model.GrantId, expiresOnUtc = Model.ExpiresOnUtc, windowMinutes = Model.WindowMinutes } + : new { type = "resgrid-adp-grant", success = false, error = Model.Error }, + new Newtonsoft.Json.JsonSerializerSettings { StringEscapeHandling = Newtonsoft.Json.StringEscapeHandling.EscapeHtml }); +} + + + + + + Resgrid + + + + + diff --git a/Web/Resgrid.Web/Views/SharedSession/Locked.cshtml b/Web/Resgrid.Web/Views/SharedSession/Locked.cshtml new file mode 100644 index 000000000..e3add5cd9 --- /dev/null +++ b/Web/Resgrid.Web/Views/SharedSession/Locked.cshtml @@ -0,0 +1,151 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.SharedSessionLockedViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["SharedLockedHeader"]; + Layout = null; + string Js(string key) => Newtonsoft.Json.JsonConvert.SerializeObject(localizer[key].Value); + var choices = Model.Offers("passkey") || Model.Offers("passkey_approval"); +} + + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + + @* A shared workstation's lock screen (passkey plan section 12.5.3): nothing of the locked work, only who may unlock it. *@ +
    +
    +
    +
    + Resgrid +
    +

    @localizer["SharedLockedHeader"]

    +

    @(Model.LockedWhenIdle ? localizer["SharedLockedIdle"] : localizer["SharedLockedExplicit"])

    +

    + @string.Format(localizer["SharedLockedOperator"].Value, Model.Operator) + @if (!string.IsNullOrWhiteSpace(Model.InstallationLabel)) + { +
    @Model.InstallationLabel + } +

    +

    @string.Format(localizer["SharedLockedInstruction"].Value, Model.Operator)

    + +
    + @if (!string.IsNullOrWhiteSpace(Model.Error)) + { + + } + + @if (Model.Unavailable != null) + { + + } + else + { +
    + @Html.AntiForgeryToken() + + + @if (Model.Offers("totp")) + { +
    + + +
    + + } + @if (Model.Offers("passkey")) + { + + } + @if (Model.Offers("passkey_approval")) + { + + } + @if (Model.Offers("federated")) + { + + } + + + +
    + } + +
    +
    + @Html.AntiForgeryToken() + + +
    +
    + @Html.AntiForgeryToken() + + +
    +
    +
    +
    +
    + + @Html.Partial("_ScriptsPartial") + + @if (choices && Model.Unavailable == null) + { + + + + } + + diff --git a/Web/Resgrid.Web/Views/SharedSession/Workstation.cshtml b/Web/Resgrid.Web/Views/SharedSession/Workstation.cshtml new file mode 100644 index 000000000..17abf1f45 --- /dev/null +++ b/Web/Resgrid.Web/Views/SharedSession/Workstation.cshtml @@ -0,0 +1,64 @@ +@using Microsoft.Extensions.Localization +@model Resgrid.Web.Models.AccountViewModels.SharedWorkstationViewModel +@inject IStringLocalizer localizer +@{ + ViewData["Title"] = "Resgrid | " + localizer["SharedWorkstationTitle"]; + Layout = null; +} + + + + + + + @ViewData["Title"] + + @Html.Partial("_StylePartial") + + + @* This browser's own setting (passkey plan section 12.5.2): a station label that can only make later sign-ins stricter. *@ +
    +
    +
    +
    + Resgrid +
    +

    @localizer["SharedWorkstationTitle"]

    +

    @localizer["SharedWorkstationDescription"]

    + +
    + @if (!string.IsNullOrWhiteSpace(Model.Status)) + { +
    @Model.Status
    + } + + @if (!Model.Available) + { + + } + else + { +
    + @Html.AntiForgeryToken() +
    + + +
    +
    + + + @localizer["SharedWorkstationLabelHelp"] +
    + +
    + } + +

    + @localizer["BackToSignInLink"] +

    +
    +
    +
    +
    + + diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.mfa.choice.js b/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.mfa.choice.js new file mode 100644 index 000000000..f2f18997d --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.mfa.choice.js @@ -0,0 +1,178 @@ +// Second-factor choices other than a typed code, for Web sign-in and Web step-up (passkey plan sections 7.1, 7.5 rules 2 +// and 5, and 7.9): a passkey for the web, and approval from the user's Responder. The server issues every option and +// decides every outcome; this page only runs the browser prompt, shows the approval number on this screen, and goes where +// the server says once it succeeds. Nothing is stored in the browser. +// +// resgridMfaChoice.init({ +// antiForgeryToken: function () { ... }, // the page's antiforgery token +// extra: function () { return { returnUrl: ... } }, // posted with every request (optional) +// passkeyOptionsUrl, verifyPasskeyUrl, // omit to leave passkeys off +// requestApprovalUrl, approvalStatusUrl, completeApprovalUrl, // omit to leave approval off +// messages: { passkey_failed, passkey_cancelled, passkey_not_supported, approval_waiting, approval_denied, +// approval_expired, failed, }, +// navigate: function (url) { ... } // optional; window.location.assign by default +// }); +// +// Markup: #mfaUsePasskey, #mfaUseResponder, #mfaApprovalPanel, #mfaApprovalNumber, #mfaApprovalStatus, #mfaApprovalCancel, +// #mfaChoiceError. +var resgridMfaChoice = (function () { + 'use strict'; + + var APPROVAL_POLL_MS = 2000; + var settings = null; + var approvalId = null; + var approvalTimer = null; + var busy = false; + + function post(url, data) { + var body = $.extend({ __RequestVerificationToken: settings.antiForgeryToken() }, settings.extra ? settings.extra() : {}, data || {}); + return $.post(url, body); + } + + function text(key) { + var messages = settings.messages || {}; + return messages[key] || messages.failed || ''; + } + + function showError(key) { + busy = false; + $('#mfaUsePasskey, #mfaUseResponder').prop('disabled', false); + $('#mfaChoiceError').text(text(key)).show(); + } + + function clearError() { + $('#mfaChoiceError').hide().text(''); + } + + function isLocal(url) { + return typeof url === 'string' && url.charAt(0) === '/' && url.charAt(1) !== '/' && url.charAt(1) !== '\\'; + } + + // A refusal the user can recover from here shows its message; one that ends the sign-in (expired, too many attempts) + // names where to start again, and the page goes there. + function failed(response, fallback) { + if (response && isLocal(response.restart)) { + settings.navigate(response.restart); + return; + } + + showError(response && response.error ? response.error : fallback); + } + + // The server answers { success: true, redirect } once the sign-in or step-up is complete; the redirect is always a + // local path it chose, so anything else is refused here too. + function finished(response) { + if (response && response.success && isLocal(response.redirect)) { + settings.navigate(response.redirect); + return; + } + + failed(response, 'failed'); + } + + function usePasskey() { + if (busy) + return; + stopApproval(); + clearError(); + if (!window.resgridPasskeys || !window.resgridPasskeys.isSupported()) { + showError('passkey_not_supported'); + return; + } + + busy = true; + $('#mfaUsePasskey').prop('disabled', true); + post(settings.passkeyOptionsUrl).done(function (start) { + if (!start || !start.success) { + failed(start, 'passkey_failed'); + return; + } + + window.resgridPasskeys.authenticate(start.options).then(function (credential) { + post(settings.verifyPasskeyUrl, { requestId: start.requestId, credential: JSON.stringify(credential) }) + .done(finished).fail(function () { showError('passkey_failed'); }); + }, function (error) { + // A closed prompt is the user's choice, never a failed verification. + var outcome = error && error.outcome; + showError(outcome === 'cancelled' ? 'passkey_cancelled' : outcome === 'not_supported' ? 'passkey_not_supported' : 'passkey_failed'); + }); + }).fail(function () { showError('passkey_failed'); }); + } + + function stopApproval() { + if (approvalTimer) { + window.clearInterval(approvalTimer); + approvalTimer = null; + } + approvalId = null; + $('#mfaApprovalPanel').hide(); + } + + // Approve with Responder: the number is shown on this screen only, the decision is polled, and an approval is used once. + function useResponder() { + if (busy) + return; + stopApproval(); + clearError(); + busy = true; + $('#mfaUseResponder').prop('disabled', true); + post(settings.requestApprovalUrl).done(function (response) { + if (!response || !response.success) { + failed(response, 'failed'); + return; + } + + busy = false; + $('#mfaUseResponder').prop('disabled', false); + approvalId = response.approvalRequestId; + $('#mfaApprovalNumber').text(response.matchNumber); + $('#mfaApprovalStatus').text(text('approval_waiting')); + $('#mfaApprovalPanel').show(); + approvalTimer = window.setInterval(pollApproval, APPROVAL_POLL_MS); + }).fail(function () { showError('failed'); }); + } + + function pollApproval() { + var id = approvalId; + if (!id) + return; + + post(settings.approvalStatusUrl, { approvalRequestId: id }).done(function (response) { + if (approvalId !== id) + return; + + var state = response && response.success ? response.state : null; + if (state === 'pending') + return; + + stopApproval(); + if (state === 'approved') { + busy = true; + post(settings.completeApprovalUrl, { approvalRequestId: id }).done(finished).fail(function () { showError('failed'); }); + return; + } + + if (!state) { + failed(response, 'failed'); + return; + } + + showError(state === 'denied' ? 'approval_denied' : 'approval_expired'); + }); + } + + function init(options) { + settings = $.extend({ navigate: function (url) { window.location.assign(url); } }, options); + busy = false; + stopApproval(); + $('#mfaUsePasskey').off('click.mfaChoice').on('click.mfaChoice', usePasskey); + $('#mfaUseResponder').off('click.mfaChoice').on('click.mfaChoice', useResponder); + $('#mfaApprovalCancel').off('click.mfaChoice').on('click.mfaChoice', function () { + stopApproval(); + busy = false; + $('#mfaUsePasskey, #mfaUseResponder').prop('disabled', false); + }); + } + + return { init: init, stopApproval: stopApproval }; +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.passkeys.js b/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.passkeys.js new file mode 100644 index 000000000..d0a1b027e --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/passkeys/resgrid.passkeys.js @@ -0,0 +1,143 @@ +// Passkey ceremonies for Resgrid Web (passkey plan sections 6.1, 7.1 and 8.1). The server sends WebAuthn options as JSON +// with base64url binary fields; this turns them into what navigator.credentials needs, runs the browser's prompt, and +// returns the credential as PublicKeyCredential.toJSON() would (base64url strings), which is what the server verifies. +// Nothing here stores a credential, a challenge or a response: each lives only for the one ceremony. +(function (window) { + 'use strict'; + + function toBuffer(value) { + if (value instanceof ArrayBuffer) + return value; + if (ArrayBuffer.isView(value)) + return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); + var base64 = String(value).replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) + base64 += '='; + var binary = window.atob(base64); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + } + + function toBase64Url(buffer) { + if (buffer === null || buffer === undefined) + return null; + var bytes = new Uint8Array(buffer instanceof ArrayBuffer ? buffer : buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + var binary = ''; + for (var i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function parse(options) { + return typeof options === 'string' ? JSON.parse(options) : JSON.parse(JSON.stringify(options || {})); + } + + function descriptors(list) { + return (list || []).map(function (item) { + var descriptor = { type: item.type || 'public-key', id: toBuffer(item.id) }; + if (item.transports) + descriptor.transports = item.transports; + return descriptor; + }); + } + + function creationOptions(options) { + var o = parse(options); + o.challenge = toBuffer(o.challenge); + if (o.user) + o.user.id = toBuffer(o.user.id); + if (o.excludeCredentials) + o.excludeCredentials = descriptors(o.excludeCredentials); + return o; + } + + function requestOptions(options) { + var o = parse(options); + o.challenge = toBuffer(o.challenge); + if (o.allowCredentials) + o.allowCredentials = descriptors(o.allowCredentials); + return o; + } + + // The credential as the server expects it: WebAuthn Level 3 toJSON() where the browser has it, the same shape + // built by hand where it does not. + function toJson(credential) { + if (typeof credential.toJSON === 'function') { + try { + return credential.toJSON(); + } catch (e) { + // Some implementations throw for extension results they cannot serialise; fall through to the manual shape. + } + } + + var response = credential.response; + var json = { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + clientExtensionResults: typeof credential.getClientExtensionResults === 'function' ? credential.getClientExtensionResults() : {}, + response: { clientDataJSON: toBase64Url(response.clientDataJSON) } + }; + if (credential.authenticatorAttachment) + json.authenticatorAttachment = credential.authenticatorAttachment; + + if (response.attestationObject) { + json.response.attestationObject = toBase64Url(response.attestationObject); + if (typeof response.getTransports === 'function') + json.response.transports = response.getTransports(); + } else { + json.response.authenticatorData = toBase64Url(response.authenticatorData); + json.response.signature = toBase64Url(response.signature); + json.response.userHandle = response.userHandle ? toBase64Url(response.userHandle) : null; + } + + return json; + } + + function isSupported() { + return !!(window.PublicKeyCredential && window.navigator.credentials && window.navigator.credentials.create && window.navigator.credentials.get); + } + + // A closed or refused prompt is the user's choice, never a failed verification (plan section 11): callers show it as + // "cancelled" and count nothing against the user. + function outcome(error) { + var name = error && error.name; + if (name === 'NotAllowedError' || name === 'AbortError') + return 'cancelled'; + if (name === 'InvalidStateError') + return 'already_registered'; + if (name === 'NotSupportedError' || name === 'SecurityError') + return 'not_supported'; + return 'failed'; + } + + function run(kind, options) { + if (!isSupported()) + return Promise.reject({ outcome: 'not_supported' }); + + var request = kind === 'create' + ? { publicKey: creationOptions(options) } + : { publicKey: requestOptions(options) }; + + return window.navigator.credentials[kind](request).then(function (credential) { + if (!credential) + throw { outcome: 'cancelled' }; + return toJson(credential); + }, function (error) { + throw { outcome: outcome(error), name: error && error.name }; + }); + } + + window.resgridPasskeys = { + isSupported: isSupported, + register: function (options) { return run('create', options); }, + authenticate: function (options) { return run('get', options); }, + // Exposed for tests. + creationOptions: creationOptions, + requestOptions: requestOptions, + toJson: toJson, + toBase64Url: toBase64Url + }; +})(window); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.guard.js b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.guard.js new file mode 100644 index 000000000..76e5c32d2 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.guard.js @@ -0,0 +1,41 @@ +// Shared workstation sessions (passkey plan section 12.5.4). Loaded in the page head: a page brought back from history (the +// back or forward button, or the browser's back-forward cache) stays hidden until the server confirms that this session is +// not locked, so the back button never shows a locked operator's work. resgrid.shared.session.js reveals it again or goes +// to the lock screen. +var resgridSharedGuard = (function () { + 'use strict'; + + var CONCEALED = 'rg-shared-concealed'; + var root = document.documentElement; + + var style = document.createElement('style'); + style.textContent = 'html.' + CONCEALED + ' body { visibility: hidden !important; }'; + (document.head || root).appendChild(style); + + function concealed() { + return (' ' + root.className + ' ').indexOf(' ' + CONCEALED + ' ') >= 0; + } + + function conceal() { + if (!concealed()) + root.className = (root.className ? root.className + ' ' : '') + CONCEALED; + } + + function reveal() { + root.className = (' ' + root.className + ' ').replace(' ' + CONCEALED + ' ', ' ').trim(); + } + + var entries = window.performance && performance.getEntriesByType ? performance.getEntriesByType('navigation') : []; + var fromHistory = entries && entries.length + ? entries[0].type === 'back_forward' + : !!(window.performance && performance.navigation && performance.navigation.type === 2); + if (fromHistory) + conceal(); + + window.addEventListener('pageshow', function (event) { + if (event.persisted) + conceal(); + }); + + return { conceal: conceal, reveal: reveal, concealed: concealed }; +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.locked.js b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.locked.js new file mode 100644 index 000000000..7a5f5be9c --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.locked.js @@ -0,0 +1,80 @@ +// The lock screen of a shared workstation (passkey plan section 12.5.3). Every other tab of this site locks with it, and once +// the same operator unlocks in any tab, the others go back to where they were. End shift and Switch operator sign every tab +// out. Nothing from the locked work is kept here. +var resgridSharedLocked = (function () { + 'use strict'; + + var CHANNEL = 'resgrid-shared-session'; + var root = document.getElementById('sharedLocked'); + var channel = null; + var leaving = false; + + function attribute(name) { + return root ? root.getAttribute('data-' + name) : null; + } + + function broadcast(message) { + try { + if (channel) + channel.postMessage(message); + } catch (e) { + // Other tabs follow the server on their own. + } + } + + function leave(url) { + if (leaving) + return; + leaving = true; + window.location.assign(url); + } + + function back() { + leave(attribute('return-url') || '/User/Home/Dashboard'); + } + + // Unlocked elsewhere: go back. Ended: sign in. Still locked: stay. + function check() { + if (leaving || !root) + return; + window.fetch(attribute('status-url'), { + method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } + }).then(function (response) { + if (response.status === 401) { + leave('/Account/LogOn'); + return null; + } + return response.ok ? response.json() : null; + }).then(function (status) { + if (status && status.shared && !status.locked) + back(); + }).catch(function () { }); + } + + if (window.BroadcastChannel) { + channel = new window.BroadcastChannel(CHANNEL); + channel.onmessage = function (event) { + var message = event && event.data; + if (!message) + return; + if (message.type === 'active') + check(); + else if (message.type === 'ended') + leave('/Account/LogOn'); + }; + } + + // Every tab of this site locks with this one. + broadcast({ type: 'locked' }); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') + check(); + }); + + Array.prototype.forEach.call(document.querySelectorAll('form input[name="switchOperator"]'), function (input) { + input.form.addEventListener('submit', function () { broadcast({ type: 'ended' }); }); + }); + + return { leave: leave, check: check }; +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.session.js b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.session.js new file mode 100644 index 000000000..83a94dee3 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/shared/resgrid.shared.session.js @@ -0,0 +1,273 @@ +// The shared workstation bar (passkey plan sections 10.5 and 12.5.4). The server owns the idle lock and the shift end; this +// page only follows them. Real input (keys, pointer, wheel, touch) is reported as operator activity at most every 30 seconds +// across all of this site's tabs, and polling never is. The page warns before the idle lock, goes to the lock screen as soon +// as the server says the session is locked, locks every tab together, and shows a page brought back from history only once +// the server confirms the session is still unlocked. +// +// resgridSharedSession.init({ messages: { locksIn, idleWarning, shiftEndsSoon }, navigate: function (url) { ... } }); +// +// Markup: #rgSharedSession (data-status-url, data-locked-url, data-shift-ended-url), #rgSharedCountdown, #rgSharedWarning, +// #rgSharedWarningText, #rgSharedStay, #rgSharedLockForm, and End shift forms posting switchOperator. +var resgridSharedSession = (function () { + 'use strict'; + + var ACTIVITY_HEADER = 'X-Resgrid-Operator-Activity'; + var ACTIVITY_INTERVAL_MS = 30000; + var POLL_INTERVAL_MS = 60000; + var RECHECK_MS = 5000; + var WARNING_SECONDS = 60; + var SHIFT_NOTICE_SECONDS = 600; + var ACTIVITY_KEY = 'resgrid.sharedSession.lastActivity'; + var CHANNEL = 'resgrid-shared-session'; + var INPUT_EVENTS = ['keydown', 'pointerdown', 'wheel', 'touchstart']; + + var settings = null; + var bar = null; + var channel = null; + var idleDeadline = null; + var shiftDeadline = null; + var leaving = false; + var checking = false; + var lastCheck = 0; + var lastActivity = 0; + + function attribute(name) { + return bar.getAttribute('data-' + name); + } + + function here() { + return window.location.pathname + window.location.search; + } + + function format(template, value) { + return String(template || '').replace('{0}', value); + } + + function clockText(seconds) { + var minutes = Math.floor(seconds / 60); + var rest = seconds % 60; + return minutes + ':' + (rest < 10 ? '0' : '') + rest; + } + + function broadcast(message) { + try { + if (channel) + channel.postMessage(message); + } catch (e) { + // A closed channel only means no other tab hears it; each one still follows the server. + } + } + + function go(url) { + if (leaving) + return; + leaving = true; + settings.navigate(url); + } + + function goLocked(tell) { + if (leaving) + return; + if (tell !== false) + broadcast({ type: 'locked' }); + go(attribute('locked-url') + '?returnUrl=' + encodeURIComponent(here())); + } + + // The session ended or its shift ran out: sign in again. + function goSignIn() { + go(shiftDeadline !== null && Date.now() >= shiftDeadline + ? attribute('shift-ended-url') + : '/Account/LogOn?returnUrl=' + encodeURIComponent(here())); + } + + function fetchStatus(activity) { + var headers = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; + if (activity) + headers[ACTIVITY_HEADER] = '1'; + + lastCheck = Date.now(); + return window.fetch(attribute('status-url'), { method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: headers }) + .then(function (response) { + if (response.status === 401) + return response.json().catch(function () { return {}; }).then(function (body) { return { unauthorized: true, body: body || {} }; }); + return response.ok ? response.json() : null; + }) + .catch(function () { return null; }); + } + + // What the server said; an unreachable server leaves the last deadlines in place, and the server enforces them anyway. + function apply(status) { + if (!status || leaving) + return false; + + if (status.unauthorized) { + if (status.body.error === 'shared_session_locked') + goLocked(); + else + goSignIn(); + return false; + } + + if (status.locked) { + goLocked(); + return false; + } + + var now = Date.now(); + if (typeof status.idleLocksInSeconds === 'number') + idleDeadline = now + status.idleLocksInSeconds * 1000; + if (typeof status.shiftEndsInSeconds === 'number') + shiftDeadline = now + status.shiftEndsInSeconds * 1000; + if (window.resgridSharedGuard) + window.resgridSharedGuard.reveal(); + render(); + return true; + } + + function check(activity) { + if (checking) + return; + checking = true; + fetchStatus(activity).then(function (status) { + checking = false; + if (apply(status) && activity) + broadcast({ type: 'activity', idleLocksInSeconds: status.idleLocksInSeconds }); + }); + } + + function readActivity() { + try { + return parseInt(window.localStorage.getItem(ACTIVITY_KEY), 10) || 0; + } catch (e) { + return lastActivity; + } + } + + function writeActivity(now) { + lastActivity = now; + try { + window.localStorage.setItem(ACTIVITY_KEY, String(now)); + } catch (e) { + // Private mode: this tab still throttles itself. + } + } + + // Real input from the operator. Reported at most once per interval across tabs; "Stay signed in" always reports. + function activity(force) { + if (leaving) + return; + var now = Date.now(); + if (!force && now - Math.max(readActivity(), lastActivity) < ACTIVITY_INTERVAL_MS) + return; + writeActivity(now); + check(true); + } + + function render() { + var countdown = document.getElementById('rgSharedCountdown'); + var warning = document.getElementById('rgSharedWarning'); + var warningText = document.getElementById('rgSharedWarningText'); + if (idleDeadline === null) + return; + + var now = Date.now(); + var remaining = Math.max(0, Math.ceil((idleDeadline - now) / 1000)); + if (countdown) + countdown.textContent = format(settings.messages.locksIn, clockText(remaining)); + + var notices = []; + if (remaining <= WARNING_SECONDS) + notices.push(format(settings.messages.idleWarning, remaining)); + if (shiftDeadline !== null && shiftDeadline - now <= SHIFT_NOTICE_SECONDS * 1000) + notices.push(format(settings.messages.shiftEndsSoon, Math.max(0, Math.ceil((shiftDeadline - now) / 60000)))); + + if (warning && warningText) { + warningText.textContent = notices.join(' '); + warning.style.display = notices.length ? '' : 'none'; + } + } + + function tick() { + if (leaving) + return; + var now = Date.now(); + var due = (idleDeadline !== null && now >= idleDeadline) || (shiftDeadline !== null && now >= shiftDeadline); + if ((due && now - lastCheck >= 1000) || now - lastCheck >= POLL_INTERVAL_MS || + (window.resgridSharedGuard && window.resgridSharedGuard.concealed() && now - lastCheck >= RECHECK_MS)) + check(false); + render(); + } + + function onMessage(event) { + var message = event && event.data; + if (!message || leaving) + return; + if (message.type === 'locked') + goLocked(false); + else if (message.type === 'ended') + goSignIn(); + else if (message.type === 'activity' && typeof message.idleLocksInSeconds === 'number') { + idleDeadline = Date.now() + message.idleLocksInSeconds * 1000; + render(); + } + } + + function init(options) { + settings = options || {}; + settings.messages = settings.messages || {}; + settings.navigate = settings.navigate || function (url) { window.location.assign(url); }; + bar = document.getElementById('rgSharedSession'); + if (!bar) + return; + + if (window.BroadcastChannel) { + channel = new window.BroadcastChannel(CHANNEL); + channel.onmessage = onMessage; + } + + INPUT_EVENTS.forEach(function (name) { + document.addEventListener(name, function () { activity(false); }, { passive: true, capture: true }); + }); + + var stay = document.getElementById('rgSharedStay'); + if (stay) + stay.addEventListener('click', function () { activity(true); }); + + var lockForm = document.getElementById('rgSharedLockForm'); + if (lockForm) + lockForm.addEventListener('submit', function () { broadcast({ type: 'locked' }); leaving = true; }); + + Array.prototype.forEach.call(document.querySelectorAll('form input[name="switchOperator"]'), function (input) { + input.form.addEventListener('submit', function () { broadcast({ type: 'ended' }); leaving = true; }); + }); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible') + check(false); + }); + + window.addEventListener('pageshow', function (event) { + if (event.persisted) { + leaving = false; + check(false); + } + }); + + // A request this page makes after the session locked is refused; go to the lock screen rather than show an error. + if (window.jQuery) { + window.jQuery(document).ajaxError(function (event, xhr) { + if (xhr && xhr.status === 401 && xhr.responseJSON && xhr.responseJSON.error === 'shared_session_locked') + goLocked(); + }); + } + + // This page came from the server for an unlocked session: tabs still on the lock screen can go back. + fetchStatus(false).then(function (status) { + if (apply(status)) + broadcast({ type: 'active' }); + }); + window.setInterval(tick, 1000); + } + + return { init: init }; +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/common/sso/resgrid.sso.return.js b/Web/Resgrid.Web/wwwroot/js/app/common/sso/resgrid.sso.return.js new file mode 100644 index 000000000..48f7ba80a --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/common/sso/resgrid.sso.return.js @@ -0,0 +1,21 @@ +// Continues a return from the department's identity provider (passkey plan section 7.7.2; workbook section 12). The browser +// arrives on Account/SsoReturn from the provider, a cross-site navigation that carries none of this site's SameSite=Strict +// cookies, so that page reads and changes nothing. This script posts its code and state on from this site's own page: a +// same-site request, which carries them. Without script, the page's button does the same. +(function () { + 'use strict'; + + function go() { + var form = document.getElementById('ssoReturnContinue'); + if (!form || form.getAttribute('data-sent') === '1') + return; + + form.setAttribute('data-sent', '1'); + form.submit(); + } + + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', go); + else + go(); +})(); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js index 0fc986fff..115f8c96c 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js @@ -16,7 +16,9 @@ var EXPIRES_FIELD = '__ResgridProtectedGrantExpiresOn'; var DEFAULT_WARN_SECONDS = 120; - var settings = null; // { verifyUrl, requestGrantUrl, revealUrl, revealData, antiForgeryToken, messages, onRevealed, onConcealed, onRenewed, grantExpiresOnUtc, bindForms, warnBeforeSeconds } + var settings = null; // { verifyUrl, requestGrantUrl, revealUrl, revealData, antiForgeryToken, messages, onRevealed, onConcealed, onRenewed, grantExpiresOnUtc, bindForms, warnBeforeSeconds, + // methodsUrl, passkeyOptionsUrl, verifyPasskeyUrl, requestApprovalUrl, approvalStatusUrl, completeApprovalUrl } + var APPROVAL_POLL_MS = 2000; var grantToken = null; var serverGrant = false; // the page arrived holding a grant in a bound form's hidden field (the *Revealed actions) var expiresAt = null; // epoch milliseconds when known @@ -28,6 +30,9 @@ var pendingAction = null; // what to do once a grant is acquired; the reveal when nothing else is waiting var pendingForm = null; var passThrough = null; // the form whose submit is being re-dispatched with the grant attached + var approvalId = null; // this page's pending Responder request, while the prompt waits for it + var approvalTimer = null; + var providerWindow = null; // the popup running this page's provider step-up, the only window whose answer is accepted function fields() { return $('[data-adp-field], [data-adp-name]'); @@ -380,7 +385,13 @@ generic: 'The request failed. Try again.', expiring: 'Your verification expires in {0}. Re-verify now to keep working without losing changes.', expired: 'Your verification has expired. Re-verify to continue; unsaved changes stay on this page until you do.', - renew: 'Re-verify' + renew: 'Re-verify', + passkey_cancelled: 'The passkey prompt was closed. Nothing was changed.', + passkey_failed: 'The passkey could not be used. Try again, or use your authenticator app.', + approval_waiting: 'Waiting for your approval in Responder...', + approval_denied: 'The request was denied in Responder.', + approval_expired: 'The request expired before it was approved. Start again.', + provider_popup_blocked: 'Allow pop-ups for this site to verify with your identity provider.' }; function messageText(key) { @@ -461,9 +472,155 @@ } function showStepUpModal() { + stopApproval(); $('#adpStepUpError').hide().text(''); $('#adpStepUpCode').val(''); + $('#adpUsePasskey, #adpUseResponder, #adpUseProvider').hide(); $('#adpStepUpModal').modal('show'); + loadMethods(); + } + + function stepUpError(code) { + $('#adpStepUpError').text(errorText(code)).show(); + } + + // Which other ways the server says this user can verify here (passkey plan section 7.5 rule 5). The code stays the + // default; a passkey or Responder approval appears only when the server lists it. A host without the endpoint, or any + // failure, keeps the code alone. + function loadMethods() { + if (!settings.methodsUrl) + return; + + $.ajax({ url: settings.methodsUrl, method: 'GET' }).done(function (response) { + var methods = (response && response.success && response.methods) || []; + var passkeys = window.resgridPasskeys && window.resgridPasskeys.isSupported(); + $('#adpUsePasskey').toggle(methods.indexOf('passkey') >= 0 && !!passkeys); + $('#adpUseResponder').toggle(methods.indexOf('passkey_approval') >= 0); + $('#adpUseProvider').toggle(methods.indexOf('federated') >= 0 && !!settings.federatedUrl); + }); + } + + // A verification the server turned into a grant: the waiting action runs before the prompt closes. + function onVerified(response) { + if (response && response.success) { + stopApproval(); + grantAcquired(response.grantToken, response.expiresOnUtc); + $('#adpStepUpModal').modal('hide'); + return; + } + + stepUpError(response && response.error); + } + + function verifyWithPasskey() { + stopApproval(); + $('#adpStepUpError').hide(); + $.post(settings.passkeyOptionsUrl, { __RequestVerificationToken: settings.antiForgeryToken }).done(function (start) { + if (!start || !start.success) { + stepUpError(start && start.error); + return; + } + + window.resgridPasskeys.authenticate(start.options).then(function (credential) { + $.post(settings.verifyPasskeyUrl, { + __RequestVerificationToken: settings.antiForgeryToken, + requestId: start.requestId, + credential: JSON.stringify(credential) + }).done(onVerified).fail(function () { + stepUpError(null); + }); + }, function (error) { + // A closed prompt is the user's choice, never a failed verification. + stepUpError(error && error.outcome === 'cancelled' ? 'passkey_cancelled' : 'passkey_failed'); + }); + }).fail(function () { + stepUpError(null); + }); + } + + // Provider step-up (plan section 7.8): the department's identity provider runs in a popup, which posts the grant back to this + // page on this site only. The page accepts an answer only from the popup it opened. + function verifyWithProvider() { + stopApproval(); + $('#adpStepUpError').hide(); + var name = 'resgridAdpProvider'; + var popup = window.open('about:blank', name, 'width=520,height=680'); + if (!popup) { + stepUpError('provider_popup_blocked'); + return; + } + + providerWindow = popup; + var form = $('
    ').attr('action', settings.federatedUrl).attr('target', name); + form.append($('').val(settings.antiForgeryToken)); + form.append($('')); + $('body').append(form); + form[0].submit(); + form.remove(); + } + + function onProviderMessage(event) { + var data = event && event.data; + if (!providerWindow || event.source !== providerWindow || event.origin !== window.location.origin || !data || data.type !== 'resgrid-adp-grant') + return; + + providerWindow = null; + onVerified(data.success ? { success: true, grantToken: data.grantToken, expiresOnUtc: data.expiresOnUtc } : { success: false, error: data.error }); + } + + function stopApproval() { + if (approvalTimer) { + window.clearInterval(approvalTimer); + approvalTimer = null; + } + approvalId = null; + $('#adpApprovalPanel').hide(); + } + + // Approve with Responder (plan section 7.9): show the number on this screen only, wait for the decision, then use it. + function requestApproval() { + stopApproval(); + $('#adpStepUpError').hide(); + $.post(settings.requestApprovalUrl, { __RequestVerificationToken: settings.antiForgeryToken }).done(function (response) { + if (!response || !response.success) { + stepUpError(response && response.error); + return; + } + + approvalId = response.approvalRequestId; + $('#adpApprovalNumber').text(response.matchNumber); + $('#adpApprovalStatus').text(messageText('approval_waiting')); + $('#adpApprovalPanel').show(); + approvalTimer = window.setInterval(pollApproval, APPROVAL_POLL_MS); + }).fail(function () { + stepUpError(null); + }); + } + + function pollApproval() { + var id = approvalId; + if (!id) + return; + + $.post(settings.approvalStatusUrl, { __RequestVerificationToken: settings.antiForgeryToken, approvalRequestId: id }).done(function (response) { + if (approvalId !== id) + return; + + var state = response && response.success ? response.state : null; + if (state === 'pending') + return; + + stopApproval(); + if (state === 'approved') { + $.post(settings.completeApprovalUrl, { __RequestVerificationToken: settings.antiForgeryToken, approvalRequestId: id }) + .done(onVerified).fail(function () { + stepUpError(null); + }); + return; + } + + stepUpError(state === 'denied' ? 'approval_denied' : 'approval_expired'); + }); } function verify() { @@ -642,6 +799,10 @@ $('#adpConcealButton').on('click', conceal).hide(); $('#adpStepUpSubmit').on('click', verify); + $('#adpUsePasskey').on('click', verifyWithPasskey).hide(); + $('#adpUseResponder').on('click', requestApproval).hide(); + $('#adpUseProvider').on('click', verifyWithProvider).hide(); + window.addEventListener('message', onProviderMessage); $('#adpStepUpCode').on('keypress', function (e) { if (e.which === 13) { e.preventDefault(); @@ -651,6 +812,7 @@ // Closing the prompt without a code abandons whatever was waiting on the grant. $('#adpStepUpModal').on('hidden.bs.modal', function () { + stopApproval(); if (!pendingAction) return; var form = pendingForm; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.yourdepartments.js b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.yourdepartments.js index f85d1c7bb..0fe084c7a 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.yourdepartments.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.profile.yourdepartments.js @@ -53,7 +53,13 @@ var resgrid; }).done(function () { window.location.href = resgrid.absoluteBaseUrl + '/User/Home/Dashboard'; }).fail(function (xhr) { - if (xhr.status === 403) { + if (xhr.status === 403 && xhr.responseJSON && xhr.responseJSON.error === 'step_up_required' && xhr.responseJSON.redirectUrl) { + // The department requires MFA this session has not completed: verify, then come back and switch. + window.location.href = xhr.responseJSON.redirectUrl; + } else if (xhr.status === 403 && xhr.responseJSON && xhr.responseJSON.error === 'sso_required' && xhr.responseJSON.redirectUrl) { + // The department requires its own single sign-on: sign in through its provider. + window.location.href = xhr.responseJSON.redirectUrl; + } else if (xhr.status === 403) { window.alert('This department requires its own SSO sign-in. Sign out, then sign in through that department\'s identity provider.'); } }); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js b/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js index 553d666a4..b4037cd11 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/resgrid.user.js @@ -41,11 +41,33 @@ var resgrid; $('#top-icons-area').load(resgrid.absoluteBaseUrl + '/User/Department/TopIconsArea'); const { autocomplete } = window['@algolia/autocomplete-js']; - if (autocomplete) { + const autocompleteHost = document.getElementById('autocomplete'); + if (autocomplete && autocompleteHost) { + const searchPageUrl = function (q) { return resgrid.absoluteBaseUrl + '/User/Search?q=' + encodeURIComponent(q); }; + const searchAllLabel = autocompleteHost.getAttribute('data-search-all-label') || 'Search all results'; + // Always offered while there is text: the full page searches every family, call notes included, with filters and export. + const searchAllSource = function (q) { + return { + sourceId: 'search-all', + getItems() { return [{ label: searchAllLabel, summary: q, url: searchPageUrl(q) }]; }, + getItemUrl({ item }) { return item.url; }, + onSelect(event) { if (event.item && event.item.url) { window.location.assign(event.item.url); } }, + templates: { + item({ item, html }) { + return html``; + }, + }, + }; + }; autocomplete({ container: '#autocomplete', - placeholder: 'Search Resgrid', + placeholder: autocompleteHost.getAttribute('data-placeholder') || 'Search Resgrid', openOnFocus: true, + // Enter without picking a row opens the full search page for the typed text. + onSubmit({ state }) { + const q = (state.query || '').trim(); + if (q.length > 0) { window.location.assign(searchPageUrl(q)); } + }, getSources({ query }) { const q = (query || '').trim(); // One round trip serves both sections: system functionality ("Actions") and entity hits. @@ -59,7 +81,7 @@ var resgrid; if (!g) { g = { name: name, items: [] }; groups.push(g); } g.items.push(item); }); - return groups.map(g => ({ + const sources = groups.map(g => ({ sourceId: 'search-' + g.name.toLowerCase(), getItems() { return g.items; }, getItemUrl({ item }) { return item.url; }, @@ -77,8 +99,10 @@ var resgrid; }, }, })); + if (q.length > 0) { sources.push(searchAllSource(q)); } + return sources; }) - .catch(() => []); + .catch(() => q.length > 0 ? [searchAllSource(q)] : []); }, }); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.account.passkeys.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.account.passkeys.js new file mode 100644 index 000000000..d9736d06f --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.account.passkeys.js @@ -0,0 +1,139 @@ +// Passkeys on the Web account security page (passkey plan sections 6.1 and 6.5). The server decides everything: this +// page asks it for ceremony options, runs the browser's prompt through resgridPasskeys, and sends the result back. When +// the server answers that the user must first confirm their password or verify again, it names the page to go to and +// this script follows it; nothing is decided or stored here. Text is inserted with text(), never as markup. +(function (window, $) { + 'use strict'; + + var settings = null; // { optionsUrl, completeUrl, renameUrl, removeUrl, pageUrl, signInUrl, messages, navigate } + + function go(url) { + if (settings && typeof settings.navigate === 'function') + settings.navigate(url); + else + window.location.assign(url); + } + + function token() { + return $('#passkeyAntiForgery input[name="__RequestVerificationToken"]').val(); + } + + function message(key) { + var messages = (settings && settings.messages) || {}; + return messages[key] || messages.failed || ''; + } + + function showError(key) { + $('#passkeyStatus').hide(); + $('#passkeyError').text(message(key)).show(); + } + + function busy(on) { + $('#passkeyAdd, .passkey-rename, .passkey-remove').prop('disabled', on); + } + + // A refusal the user can act on: confirm the password, verify again, or sign in again. Anything else is shown. + function refused(response) { + busy(false); + if (response && response.redirect) { + go(response.redirect); + return; + } + + var code = response && response.error; + showError(code === 'passkey_limit_reached' ? 'limit' : code === 'passkeys_unavailable' ? 'unavailable' : 'failed'); + } + + function reload(status) { + go(settings.pageUrl + (settings.pageUrl.indexOf('?') >= 0 ? '&' : '?') + 'passkeyStatus=' + status + '#passkeys'); + } + + function ceremonyFailed(error) { + busy(false); + var outcome = error && error.outcome; + showError(outcome === 'cancelled' ? 'cancelled' : outcome === 'not_supported' ? 'notSupported' : outcome === 'already_registered' ? 'alreadyRegistered' : 'failed'); + } + + function add() { + if (!window.resgridPasskeys || !window.resgridPasskeys.isSupported()) { + showError('notSupported'); + return; + } + + busy(true); + var name = ($('#passkeyName').val() || '').trim(); + $.post(settings.optionsUrl, { __RequestVerificationToken: token() }).done(function (start) { + if (!start || !start.success) { + refused(start); + return; + } + + window.resgridPasskeys.register(start.options).then(function (credential) { + $.post(settings.completeUrl, { + __RequestVerificationToken: token(), + requestId: start.requestId, + credential: JSON.stringify(credential), + displayName: name + }).done(function (done) { + if (done && done.success) + reload('added'); + else + refused(done); + }).fail(function () { + refused(null); + }); + }, ceremonyFailed); + }).fail(function () { + refused(null); + }); + } + + function rename(row) { + var current = row.find('.passkey-name').text(); + var name = window.prompt(message('renamePrompt'), current); + if (name === null || !name.trim() || name.trim() === current) + return; + + busy(true); + $.post(settings.renameUrl, { __RequestVerificationToken: token(), id: row.data('passkey-id'), displayName: name.trim() }) + .done(function (done) { + if (done && done.success) + reload('renamed'); + else + refused(done); + }).fail(function () { + refused(null); + }); + } + + function remove(row) { + if (!window.confirm(message('removeConfirm'))) + return; + + busy(true); + $.post(settings.removeUrl, { __RequestVerificationToken: token(), id: row.data('passkey-id') }) + .done(function (done) { + if (!done || !done.success) { + refused(done); + return; + } + + // A passkey this session signed in with ends this session too. + if (done.signedOut) + go(settings.signInUrl); + else + reload('removed'); + }).fail(function () { + refused(null); + }); + } + + window.resgridAccountPasskeys = { + init: function (options) { + settings = options || {}; + $('#passkeyAdd').on('click', add); + $(document).on('click', '.passkey-rename', function () { rename($(this).closest('tr')); }); + $(document).on('click', '.passkey-remove', function () { remove($(this).closest('tr')); }); + } + }; +})(window, jQuery); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js index 9b465f77e..5a571c02c 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.audits.js @@ -8,6 +8,11 @@ var resgrid; resgrid.common.analytics.track('Security Audits'); var textRenderer = $.fn.dataTable.render.text(); + // Localized text written by Audits.cshtml; table is the DataTables "language" option. + var strings = window.resgridAuditLogStrings; + var successfulHtml = textRenderer.display(strings.successful); + var failedHtml = textRenderer.display(strings.failed); + var viewHtml = textRenderer.display(strings.view); var table = $("#auditLogsList").DataTable({ ajax: { url: resgrid.absoluteBaseUrl + '/User/Security/GetAuditLogsList', @@ -15,10 +20,7 @@ var resgrid; }, pageLength: 50, order: [[1, 'desc']], - language: { - search: 'Search audit logs:', - searchPlaceholder: 'Name, ID, email, date/time, or type' - }, + language: strings.table, initComplete: function () { var api = this.api(); var typeColumn = api.column('auditType:name'); @@ -42,7 +44,7 @@ var resgrid; }, { data: 'Timestamp', - title: 'Timestamp', + title: strings.columns.timestamp, render: function (data, type, row) { if (type === 'sort' || type === 'type') { return row.TimestampSort == null ? -1 : row.TimestampSort; @@ -55,40 +57,40 @@ var resgrid; return data; } }, - { data: 'Type', name: 'auditType', title: 'Type', render: textRenderer }, - { data: 'Name', title: 'Logged By', render: textRenderer }, + { data: 'Type', name: 'auditType', title: strings.columns.type, render: textRenderer }, + { data: 'Name', title: strings.columns.loggedBy, render: textRenderer }, { data: 'Successful', - title: 'Result', + title: strings.columns.result, render: function (data, type) { if (type === 'display') { return data - ? 'Successful' - : 'Failed'; + ? '' + successfulHtml + '' + : '' + failedHtml + ''; } if (type === 'filter') { - return data ? 'Successful' : 'Failed'; + return data ? strings.successful : strings.failed; } return data ? 1 : 0; } }, - { data: 'Message', title: 'Message', render: textRenderer }, + { data: 'Message', title: strings.columns.message, render: textRenderer }, { data: 'SearchTerms', - title: 'Search Terms', + title: strings.columns.searchTerms, visible: false, searchable: true, orderable: false }, { data: 'AuditLogId', - title: 'Actions', + title: strings.columns.actions, orderable: false, searchable: false, render: function (data) { - return 'View'; + return '' + viewHtml + ''; } } ] diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.editshiftgroups.js b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.editshiftgroups.js index 1b3628a49..3e3599b47 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.editshiftgroups.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.editshiftgroups.js @@ -44,7 +44,7 @@ var resgrid; function addExistingGroup(group) { resgrid.shifts.editshiftgroups.groupsCount++; var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeGroupLabel = i18n.removeGroup || 'Remove Group'; + var removeGroupLabel = escapeHtml(i18n.removeGroup || 'Remove Group'); $('#groups tbody').first().append("
    " + resgrid.shifts.editshiftgroups.generateExistingGroupDropdown(group.DepartmentGroupId, editshiftgroups.groupsCount) + "" + resgrid.shifts.editshiftgroups.generateRolesTables(editshiftgroups.groupsCount) + "" + removeGroupLabel + "
    " + resgrid.shifts.editshiftgroups.generateGroupDropdown(editshiftgroups.groupsCount) + "" + resgrid.shifts.editshiftgroups.generateRolesTables(editshiftgroups.groupsCount) + "" + removeGroupLabel + "
    " + resgrid.shifts.editshiftgroups.generateRoleDropdown(count, suffix) + "" + removeRoleLabel + "
    " + resgrid.shifts.editshiftgroups.generateExistingRoleDropdown(role, count, id) + "" + removeRoleLabel + "
    ' + shiftRole + '' + rolesCount + ' ' + addRoleLabel + '
    '; return rolesTable; } @@ -171,8 +171,9 @@ var resgrid; return $.grep(groups, function (g) { return scope.groupIds.indexOf(g.GroupId) >= 0; }); } editshiftgroups.filterGroupsToScope = filterGroupsToScope; + // Used in attribute values as well as element text, so quotes are escaped too. function escapeHtml(value) { - return $('
    ').text(value == null ? '' : value).html(); + return String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } editshiftgroups.escapeHtml = escapeHtml; })(editshiftgroups = shifts.editshiftgroups || (shifts.editshiftgroups = {})); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.newshift.js b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.newshift.js index f938f7b1e..7ee42c772 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.newshift.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.newshift.js @@ -55,7 +55,7 @@ var resgrid; function addGroup() { resgrid.shifts.newshift.groupsCount++; var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeGroupLabel = i18n.removeGroup || 'Remove Group'; + var removeGroupLabel = escapeHtml(i18n.removeGroup || 'Remove Group'); $('#groups tbody').first().append("" + resgrid.shifts.newshift.generateGroupDropdown(newshift.groupsCount) + "" + resgrid.shifts.newshift.generateRolesTables(newshift.groupsCount) + "" + removeGroupLabel + ""); } newshift.addGroup = addGroup; @@ -65,9 +65,9 @@ var resgrid; newshift.roleCounter = (newshift.roleCounter || 0) + 1; var suffix = newshift.roleCounter; var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var removeRoleLabel = i18n.removeRole || 'Remove Role'; - var removeRoleTitle = i18n.removeRoleFromGroup || 'Remove this role from the group'; - var roleCountMsg = i18n.roleCountRequired || 'Role count is required'; + var removeRoleLabel = escapeHtml(i18n.removeRole || 'Remove Role'); + var removeRoleTitle = escapeHtml(i18n.removeRoleFromGroup || 'Remove this role from the group'); + var roleCountMsg = escapeHtml(i18n.roleCountRequired || 'Role count is required'); $('#groupRolesTable_' + count + ' tbody').append("" + resgrid.shifts.newshift.generateRoleDropdown(count, suffix) + "" + removeRoleLabel + ""); addGroupRoleField('groupRole_' + count + '_' + suffix); } @@ -120,10 +120,10 @@ var resgrid; newshift.generateRoleDropdown = generateRoleDropdown; function generateRolesTables(count) { var i18n = (typeof resgridShiftsI18n !== 'undefined') ? resgridShiftsI18n : {}; - var shiftRole = i18n.shiftRoleColumn || 'Shift Role'; - var rolesCount = i18n.rolesCountColumn || 'Roles Count'; - var addRoleLabel = i18n.addRoleToGroup || 'Add Role to Group'; - var addShiftRolesToGroupTitle = i18n.addShiftRolesToGroup || 'Add Shift Roles to Group'; + var shiftRole = escapeHtml(i18n.shiftRoleColumn || 'Shift Role'); + var rolesCount = escapeHtml(i18n.rolesCountColumn || 'Roles Count'); + var addRoleLabel = escapeHtml(i18n.addRoleToGroup || 'Add Role to Group'); + var addShiftRolesToGroupTitle = escapeHtml(i18n.addShiftRolesToGroup || 'Add Shift Roles to Group'); var rolesTable = '
    ' + shiftRole + '' + rolesCount + ' ' + addRoleLabel + '
    '; return rolesTable; } @@ -148,8 +148,9 @@ var resgrid; return $.grep(groups, function (g) { return scope.groupIds.indexOf(g.GroupId) >= 0; }); } newshift.filterGroupsToScope = filterGroupsToScope; + // Used in attribute values as well as element text, so quotes are escaped too. function escapeHtml(value) { - return $('
    ').text(value == null ? '' : value).html(); + return String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } newshift.escapeHtml = escapeHtml; })(newshift = shifts.newshift || (shifts.newshift = {})); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.shiftStaffing.js b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.shiftStaffing.js index e93bfc00a..c1083bbfa 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.shiftStaffing.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/shifts/resgrid.shifts.shiftStaffing.js @@ -95,7 +95,7 @@ var resgrid; if (isAdmin) { var nonGroupLabel = (typeof resgridShiftsI18n !== 'undefined' && resgridShiftsI18n.nonGroupPersonnel) ? resgridShiftsI18n.nonGroupPersonnel : 'Non - Group Personnel'; - html = '
    '; + html = '