mirror of
https://github.com/mattermost/mattermost.git
synced 2026-11-02 20:31:01 -06:00
[MM-71057] Health Dashboard PR18: rules for auth and identity (#38917)
* [MM-71057] Add health rules for auth and identity Port the script's LDAP, SAML and session checks as rules in a new rules/auth package, plus two probe rules over the leader's LDAP and SAML connection tests. Each code emits one result per cycle: unknown when its config is missing, resolved when its gate (LDAP, LDAP sync or SAML) is closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * [MM-71057] Cover the auth rules in the packet fixtures The fixture configs had no LdapSettings or SamlSettings, so every auth rule read unknown. The standalone packet now models an upgraded install without AD/LDAP or SAML; the HA packet uses SAML sign-in with AD/LDAP sync only, with matching probe results in each node's diagnostics. The mmctl --packet test packet gains the settings and user count the auth rules read, so its expected output is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * [MM-71057] Use shared unknown reasons, raise SAML_VERIFY_OFF to warning The probe rules, LDAP_RECOMMENDED_AT_SCALE and LDAP_SYNC_DUTY_CYCLE now report a missing source with the shared diagnostics, stats and jobs reasons instead of per-code ids. Only the duty cycle's "no measurable run" message stays per code. With Verify off, the SAML response signature is not validated, so a forged response is accepted; that warrants warning rather than info. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> * [MM-71057] Fold the auth probe evals into one helper Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * [MM-71057] Skip auth rules for settings that have no effect SAML_SIGNATURE_SHA1 now requires SignRequest, LDAP_SKIP_CERT requires TLS or STARTTLS, SAML IdAttribute matching handles claim URIs, and ldapEnabled no longer needs EnableSync when Enable is already true. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * [MM-71057] Tone down the LDAP sync duty cycle message Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * [MM-71057] Evaluate the LDAP and SAML rules on Cloud Cloud admins can change these settings, unlike the cloud_restrictable ones or the pages RestrictSystemAdmin hides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Keep the mmctl packet fixture under the AD/LDAP recommendation threshold The fixture's 300 users made LDAP_RECOMMENDED_AT_SCALE fire, adding an unexpected finding to the packet output tests. * Address review on the auth health rules - SAML_VERIFY_OFF is critical: with Verify off the SP skips signature validation, so a self-made response to the ACS URL signs in as anyone. - SAML_METADATA_UNREACHABLE becomes SAML_PROBE_FAILED, gated only on SAML being enabled. MM-71043 changes the probe to check the IdP certificate and IdP URL that sign-in uses instead of IdpMetadataURL. - LDAP_SYNC_DUTY_CYCLE only averages success and warning runs; running, canceled and failed runs end early and understate the load. - SAML_ID_IS_EMAIL is info, since it fires on every default SAML install, and the remediation says accounts migrate at next sign-in. - LDAP_RECOMMENDED_AT_SCALE resolves for GitLab, Google, Office 365 and OpenID too. - SAML_ID_MUTABLE also matches upn, userprincipalname and name (Entra's UPN claim). - LDAP_SYNC_INTERVAL_LOW is removed; LDAP_SYNC_DUTY_CYCLE measures the actual load for short intervals. * Simplify the auth rule test helpers Drop the single-use codes() helper, read LDAP_RECOMMENDED_AT_SCALE's state directly, and reuse configSnapshot's sections in probeSnapshot and gateSnapshot. Note why leaderProbe skips the probe section errors that its siblings check. * Tighten LDAP_RECOMMENDED_AT_SCALE and the probe rules - Count active users, not registered ones, which include deactivated accounts. - Sync-only LDAP no longer counts as SSO: it signs no one in, so those users still have local passwords. - An enabled provider resolves the rule even if another provider's setting is missing from the config. - The probe rules resolve only on OK. A disabled status while the gate is open means the leader never ran the probe, so it is unknown. - assertResults fails on a code that is not in allRules instead of skipping it. - The standalone fixture gains the GitLab, Google, Office 365 and OpenID sections a real packet always has. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
22 files changed
+2023
-15
No files matched your search
@@ -357,7 +357,7 @@ notifications:
|
||||
email:
|
||||
status: OK
|
||||
`,
|
||||
model.SupportPacketStatsFileName: "registered_users: 300\nactive_users: 300\nsingle_channel_guests: 0\nposts: 1000\n",
|
||||
model.SupportPacketStatsFileName: "registered_users: 100\nactive_users: 100\nsingle_channel_guests: 0\nposts: 1000\n",
|
||||
model.SupportPacketJobsFileName: "data_retention_jobs: []\n",
|
||||
model.SupportPacketConfigFileName: healthTestPacketConfig(),
|
||||
model.SupportPacketPluginsFileName: `{"enabled": [], "disabled": []}`,
|
||||
|
||||
@@ -14946,6 +14946,146 @@
|
||||
"translation": "Job in progress with no progress or activity for more than an hour",
|
||||
"description": "Health dashboard finding title when a background job is in progress but has neither reported progress nor recorded activity for over an hour."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_id_is_email.message",
|
||||
"translation": "IdAttribute is {{.attribute}}. Mattermost links each account to its directory entry by this attribute, so when a user's email changes (marriage, rebrand, department move) the link breaks and the user can no longer reach their existing account.",
|
||||
"description": "Health dashboard finding detail when the AD/LDAP ID attribute is an email attribute. {{.attribute}} is the configured attribute name. 'IdAttribute', 'objectGUID', 'entryUUID' and the command 'mmctl ldap idmigrate' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_id_is_email.remediation",
|
||||
"translation": "Switch IdAttribute to a stable attribute such as objectGUID (AD) or entryUUID (OpenLDAP), then migrate existing accounts with mmctl ldap idmigrate.",
|
||||
"description": "Health dashboard fix-it step when the AD/LDAP ID attribute is an email attribute. 'IdAttribute', 'objectGUID', 'entryUUID' and the command 'mmctl ldap idmigrate' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_id_is_email.title",
|
||||
"translation": "LDAP IdAttribute is an email attribute",
|
||||
"description": "Health dashboard finding title when the AD/LDAP ID attribute is an email attribute. 'IdAttribute', 'objectGUID', 'entryUUID' and the command 'mmctl ldap idmigrate' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_plain_mismatch.message",
|
||||
"translation": "Port 636 conventionally expects implicit TLS (LDAPS), but ConnectionSecurity is None or STARTTLS. This combination is usually a misconfiguration, but it can be valid behind a TLS-terminating proxy. Verify that LDAP sign-in and sync are succeeding.",
|
||||
"description": "Health dashboard finding detail when the AD/LDAP port is 636 but the connection security is None or STARTTLS. 'ConnectionSecurity', 'None', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_plain_mismatch.remediation",
|
||||
"translation": "If LDAP sign-in or sync is failing, set ConnectionSecurity to TLS for port 636. If they are working, no change is needed.",
|
||||
"description": "Health dashboard fix-it step when the AD/LDAP port is 636 but the connection security is None or STARTTLS. 'ConnectionSecurity', 'None', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_plain_mismatch.title",
|
||||
"translation": "LDAP port 636 configured without ConnectionSecurity=TLS",
|
||||
"description": "Health dashboard finding title when the AD/LDAP port is 636 but the connection security is None or STARTTLS. 'ConnectionSecurity', 'None', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_tls_mismatch.message",
|
||||
"translation": "Port 389 is conventionally plaintext or STARTTLS, while implicit TLS (LDAPS) conventionally uses 636. This combination is usually a misconfiguration, but it can be valid behind a TLS-terminating proxy or a deliberately reconfigured server. Verify that LDAP sign-in and sync are succeeding.",
|
||||
"description": "Health dashboard finding detail when the AD/LDAP port is 389 but the connection security is TLS. 'ConnectionSecurity', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_tls_mismatch.remediation",
|
||||
"translation": "If LDAP sign-in or sync is failing, use port 636 for TLS or switch ConnectionSecurity to STARTTLS for port 389. If they are working, no change is needed.",
|
||||
"description": "Health dashboard fix-it step when the AD/LDAP port is 389 but the connection security is TLS. 'ConnectionSecurity', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_port_tls_mismatch.title",
|
||||
"translation": "LDAP port 389 configured with ConnectionSecurity=TLS",
|
||||
"description": "Health dashboard finding title when the AD/LDAP port is 389 but the connection security is TLS. 'ConnectionSecurity', 'TLS', 'STARTTLS' and 'LDAPS' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_probe_failed.message",
|
||||
"translation": "The leader node's LDAP test (connect, bind and a user search) failed, so LDAP sign-in and sync may be failing.",
|
||||
"description": "Health dashboard finding detail when the AD/LDAP connection test run by the server failed. 'LdapServer', 'LdapPort', 'ConnectionSecurity' and the button label 'Test Connection' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_probe_failed.remediation",
|
||||
"translation": "Check the error details, then confirm LdapServer, LdapPort, ConnectionSecurity and the bind credentials, and that the Mattermost servers can reach the LDAP server. Use Test Connection on the AD/LDAP page to check again.",
|
||||
"description": "Health dashboard fix-it step when the AD/LDAP connection test run by the server failed. 'LdapServer', 'LdapPort', 'ConnectionSecurity' and the button label 'Test Connection' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_probe_failed.title",
|
||||
"translation": "LDAP connection test failed",
|
||||
"description": "Health dashboard finding title when the AD/LDAP connection test run by the server failed. 'LdapServer', 'LdapPort', 'ConnectionSecurity' and the button label 'Test Connection' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_query_timeout_low.message",
|
||||
"translation": "QueryTimeout is {{.timeout}} seconds. Tight timeouts cause spurious LDAP sign-in and sync failures on slow directories.",
|
||||
"description": "Health dashboard finding detail when the AD/LDAP query timeout is shorter than 10 seconds. {{.timeout}} is the configured timeout in seconds. 'QueryTimeout' and 'LdapSettings.QueryTimeout' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_query_timeout_low.remediation",
|
||||
"translation": "Raise LdapSettings.QueryTimeout to 60 seconds (the default).",
|
||||
"description": "Health dashboard fix-it step when the AD/LDAP query timeout is shorter than 10 seconds. 'QueryTimeout' and 'LdapSettings.QueryTimeout' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_query_timeout_low.title",
|
||||
"translation": "LDAP QueryTimeout is below 10 seconds",
|
||||
"description": "Health dashboard finding title when the AD/LDAP query timeout is shorter than 10 seconds. 'QueryTimeout' and 'LdapSettings.QueryTimeout' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_recommended_at_scale.message",
|
||||
"translation": "{{.users}} active user accounts are managed manually. Mattermost recommends single sign-on once a workspace has more than 100 users: manual account management at this scale leads to stale accounts, onboarding lag, and offboarding drift, each of which is a security and licensing risk.",
|
||||
"description": "Health dashboard finding detail when a workspace with more than 100 users uses no single sign-on provider (AD/LDAP, SAML, GitLab, Google, Office 365 or OpenID Connect). {{.users}} is the number of active user accounts. 'AD/LDAP', 'SAML' and 'OpenID Connect' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_recommended_at_scale.remediation",
|
||||
"translation": "Connect Mattermost to your identity provider: AD/LDAP if you run a directory, otherwise SAML or OpenID Connect.",
|
||||
"description": "Health dashboard fix-it step when a workspace with more than 100 users uses no single sign-on provider (AD/LDAP, SAML, GitLab, Google, Office 365 or OpenID Connect). 'AD/LDAP', 'SAML' and 'OpenID Connect' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_recommended_at_scale.title",
|
||||
"translation": "Over 100 users with no single sign-on",
|
||||
"description": "Health dashboard finding title when a workspace with more than 100 users uses no single sign-on provider (AD/LDAP, SAML, GitLab, Google, Office 365 or OpenID Connect). 'AD/LDAP', 'SAML' and 'OpenID Connect' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_skip_cert.message",
|
||||
"translation": "Mattermost does not verify the LDAP server's TLS certificate, so connections to the directory are open to man-in-the-middle attacks. This is acceptable only for development and testing.",
|
||||
"description": "Health dashboard finding detail when AD/LDAP certificate verification is turned off. 'SkipCertificateVerification' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_skip_cert.remediation",
|
||||
"translation": "Install the LDAP server's CA chain on the Mattermost servers and set SkipCertificateVerification to false.",
|
||||
"description": "Health dashboard fix-it step when AD/LDAP certificate verification is turned off. 'SkipCertificateVerification' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_skip_cert.title",
|
||||
"translation": "LDAP SkipCertificateVerification is enabled",
|
||||
"description": "Health dashboard finding title when AD/LDAP certificate verification is turned off. 'SkipCertificateVerification' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_duty_cycle.message",
|
||||
"translation": "SyncIntervalMinutes is {{.interval_minutes}}, and recent LDAP sync runs averaged {{.avg_run_seconds}} seconds, {{.duty_pct}}% of the interval. Long runs on a short interval keep database load elevated and can add replica lag.",
|
||||
"description": "Health dashboard finding detail when each AD/LDAP sync run takes at least a quarter of the sync interval. {{.interval_minutes}} is the sync interval in minutes, {{.avg_run_seconds}} the average run length in seconds and {{.duty_pct}} the percentage of the interval. 'SyncIntervalMinutes', 'LdapSettings.UserFilter' and 'BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_duty_cycle.message.no_runs",
|
||||
"translation": "No LDAP sync run with a measurable duration was found.",
|
||||
"description": "Health dashboard reason shown when the AD/LDAP sync duration check found no finished sync runs to measure, so its status is unknown."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_duty_cycle.remediation",
|
||||
"translation": "Narrow LdapSettings.UserFilter and BaseDN to scope the directory walk, or lengthen SyncIntervalMinutes. Aim for sync runs that take less than 25% of the interval.",
|
||||
"description": "Health dashboard fix-it step when each AD/LDAP sync run takes at least a quarter of the sync interval. 'SyncIntervalMinutes', 'LdapSettings.UserFilter' and 'BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_duty_cycle.title",
|
||||
"translation": "LDAP sync runs for a large share of its interval",
|
||||
"description": "Health dashboard finding title when each AD/LDAP sync run takes at least a quarter of the sync interval. 'SyncIntervalMinutes', 'LdapSettings.UserFilter' and 'BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_no_basedn.message",
|
||||
"translation": "LDAP sync searches the directory from BaseDN, so with it empty the sync cannot find the users it should update.",
|
||||
"description": "Health dashboard finding detail when AD/LDAP sync is enabled but no base DN is set. 'BaseDN' and 'LdapSettings.BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_no_basedn.remediation",
|
||||
"translation": "Set LdapSettings.BaseDN to the directory root you want to sync.",
|
||||
"description": "Health dashboard fix-it step when AD/LDAP sync is enabled but no base DN is set. 'BaseDN' and 'LdapSettings.BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.ldap_sync_no_basedn.title",
|
||||
"translation": "LDAP sync enabled but BaseDN is empty",
|
||||
"description": "Health dashboard finding title when AD/LDAP sync is enabled but no base DN is set. 'BaseDN' and 'LdapSettings.BaseDN' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.license_expired.message",
|
||||
"translation": "The license expired on {{.expires_at}}. The server removes an expired license 10 days after it expires, and Enterprise features stop working.",
|
||||
@@ -15216,6 +15356,96 @@
|
||||
"translation": "Data retention TimeBetweenBatchesMilliseconds is under 100",
|
||||
"description": "Health dashboard finding title when the data retention pause between delete batches is under 100 milliseconds; 'TimeBetweenBatchesMilliseconds' is a config setting name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_encrypt_off.message",
|
||||
"translation": "Assertions travel unencrypted at the SAML layer. TLS still protects the connection, so this is not a baseline security failure, but if your IdP supports assertion encryption, enabling it adds defense in depth.",
|
||||
"description": "Health dashboard finding detail when SAML assertion encryption is turned off. 'SamlSettings.Encrypt' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_encrypt_off.remediation",
|
||||
"translation": "If your IdP supports assertion encryption, enable SamlSettings.Encrypt and configure the service provider private key and public certificate.",
|
||||
"description": "Health dashboard fix-it step when SAML assertion encryption is turned off. 'SamlSettings.Encrypt' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_encrypt_off.title",
|
||||
"translation": "SAML assertion encryption is disabled",
|
||||
"description": "Health dashboard finding title when SAML assertion encryption is turned off. 'SamlSettings.Encrypt' is a config name and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_is_email.message",
|
||||
"translation": "Mattermost keys SAML accounts by email address, either because IdAttribute is empty or because it names an email attribute. When a user's email changes at the IdP, the link to their existing account breaks.",
|
||||
"description": "Health dashboard finding detail when the SAML ID attribute is empty or an email attribute. 'IdAttribute', 'SamlSettings.IdAttribute' and 'objectGUID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_is_email.remediation",
|
||||
"translation": "Configure the IdP to send a stable identifier (such as objectGUID or an immutable ID), then set SamlSettings.IdAttribute to that attribute's name. Existing accounts move to the new identifier on their own: at each user's next sign-in, Mattermost finds the account by email and stores the new ID.",
|
||||
"description": "Health dashboard fix-it step when the SAML ID attribute is empty or an email attribute. 'IdAttribute', 'SamlSettings.IdAttribute' and 'objectGUID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_is_email.title",
|
||||
"translation": "SAML IdAttribute is empty or an email attribute",
|
||||
"description": "Health dashboard finding title when the SAML ID attribute is empty or an email attribute. 'IdAttribute', 'SamlSettings.IdAttribute' and 'objectGUID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_mutable.message",
|
||||
"translation": "IdAttribute is {{.attribute}}. Usernames, uids and user principal names are often recycled across employees, and when that happens the second person inherits the first person's Mattermost account.",
|
||||
"description": "Health dashboard finding detail when the SAML ID attribute is a username, uid or user principal name that the identity provider can reassign. {{.attribute}} is the configured attribute name. 'IdAttribute', 'objectGUID' and 'Microsoft Entra ID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_mutable.remediation",
|
||||
"translation": "Prefer an identifier that is never reassigned, such as objectGUID (AD) or an immutable ID (Microsoft Entra ID), and review the IdP policy on identifier reuse.",
|
||||
"description": "Health dashboard fix-it step when the SAML ID attribute is a username, uid or user principal name that the identity provider can reassign. 'IdAttribute', 'objectGUID' and 'Microsoft Entra ID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_id_mutable.title",
|
||||
"translation": "SAML IdAttribute can be reassigned at the IdP",
|
||||
"description": "Health dashboard finding title when the SAML ID attribute is a username, uid or user principal name that the identity provider can reassign. 'IdAttribute', 'objectGUID' and 'Microsoft Entra ID' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_probe_failed.message",
|
||||
"translation": "The leader node's SAML check of the IdP certificate and IdP URL failed, so SAML sign-in is likely broken.",
|
||||
"description": "Health dashboard finding detail when the SAML configuration check run by the server failed: the IdP certificate is missing, invalid or expired, or the IdP URL is missing or invalid. 'IdpCertificateFile' and 'IdpURL' are config names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_probe_failed.remediation",
|
||||
"translation": "Check the error details, then fix the IdP certificate (IdpCertificateFile) or the IdP URL (IdpURL) in the SAML settings. If the certificate expired, upload the IdP's current signing certificate.",
|
||||
"description": "Health dashboard fix-it step when the SAML configuration check run by the server failed: the IdP certificate is missing, invalid or expired, or the IdP URL is missing or invalid. 'IdpCertificateFile' and 'IdpURL' are config names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_probe_failed.title",
|
||||
"translation": "SAML configuration check failed",
|
||||
"description": "Health dashboard finding title when the SAML configuration check run by the server failed: the IdP certificate is missing, invalid or expired, or the IdP URL is missing or invalid. 'IdpCertificateFile' and 'IdpURL' are config names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_signature_sha1.message",
|
||||
"translation": "SSO uses the SHA-1 signature algorithm, which is deprecated. SSO works today, but some identity providers are beginning to reject SHA-1, so this is a compatibility and security improvement to schedule, particularly before any IdP change or upgrade.",
|
||||
"description": "Health dashboard finding detail when SAML uses the SHA-1 signature algorithm. 'SamlSettings.SignatureAlgorithm' and 'RSAwithSHA256' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_signature_sha1.remediation",
|
||||
"translation": "When convenient, and in coordination with your IdP, set SamlSettings.SignatureAlgorithm to RSAwithSHA256.",
|
||||
"description": "Health dashboard fix-it step when SAML uses the SHA-1 signature algorithm. 'SamlSettings.SignatureAlgorithm' and 'RSAwithSHA256' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_signature_sha1.title",
|
||||
"translation": "SAML SignatureAlgorithm is SHA-1",
|
||||
"description": "Health dashboard finding title when SAML uses the SHA-1 signature algorithm. 'SamlSettings.SignatureAlgorithm' and 'RSAwithSHA256' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_verify_off.message",
|
||||
"translation": "Mattermost accepts SAML responses without verifying your IdP's signature, so it cannot tell a genuine authentication response from a forged one.",
|
||||
"description": "Health dashboard finding detail when SAML signature verification is turned off. 'SamlSettings.Verify' and 'AssertionConsumerServiceURL' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_verify_off.remediation",
|
||||
"translation": "Set SamlSettings.Verify to true and make sure AssertionConsumerServiceURL is set. Enable it in coordination with your IdP and test sign-in, since verification requires the IdP certificate to be configured correctly.",
|
||||
"description": "Health dashboard fix-it step when SAML signature verification is turned off. 'SamlSettings.Verify' and 'AssertionConsumerServiceURL' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.saml_verify_off.title",
|
||||
"translation": "SAML signature verification is disabled",
|
||||
"description": "Health dashboard finding title when SAML signature verification is turned off. 'SamlSettings.Verify' and 'AssertionConsumerServiceURL' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.scale_es_recommended.message",
|
||||
"translation": "The workspace has {{.posts}} posts and search runs against the database. Elasticsearch is recommended above 2,500,000 posts and required above 5,000,000.",
|
||||
@@ -15306,6 +15536,21 @@
|
||||
"translation": "More users than licensed seats",
|
||||
"description": "Health dashboard finding title when more users take a seat than the license covers."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.session_extend_off.message",
|
||||
"translation": "Sessions expire on a fixed schedule regardless of activity, so active users are signed out. Most deployments benefit from keeping active users signed in.",
|
||||
"description": "Health dashboard finding detail when sessions are not extended while users are active. 'ExtendSessionLengthWithActivity' and 'ServiceSettings.ExtendSessionLengthWithActivity' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.session_extend_off.remediation",
|
||||
"translation": "Enable ServiceSettings.ExtendSessionLengthWithActivity unless your security policy forbids it.",
|
||||
"description": "Health dashboard fix-it step when sessions are not extended while users are active. 'ExtendSessionLengthWithActivity' and 'ServiceSettings.ExtendSessionLengthWithActivity' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.session_extend_off.title",
|
||||
"translation": "ExtendSessionLengthWithActivity is disabled",
|
||||
"description": "Health dashboard finding title when sessions are not extended while users are active. 'ExtendSessionLengthWithActivity' and 'ServiceSettings.ExtendSessionLengthWithActivity' are config or product names and should not be translated."
|
||||
},
|
||||
{
|
||||
"id": "health.rule.site_url_empty.message",
|
||||
"translation": "SiteURL is required for webhook URLs, OAuth callbacks, mobile push payloads, and email links. Integrations are broken while it is empty.",
|
||||
|
||||
@@ -136,6 +136,9 @@ func TestReadGoldenPackets(t *testing.T) {
|
||||
expected: []finding{
|
||||
{Code: "COMPLIANCE_NO_DIR", State: healthcheck.StateFiring, Subject: complianceDirSubject},
|
||||
{Code: "JOB_FAILED", State: healthcheck.StateFiring, Subject: model.JobTypeLdapSync},
|
||||
{Code: "LDAP_PROBE_FAILED", State: healthcheck.StateUnknown, Subject: "LdapSettings.LdapServer"},
|
||||
{Code: "LDAP_RECOMMENDED_AT_SCALE", State: healthcheck.StateFiring, Subject: "LdapSettings.Enable"},
|
||||
{Code: "LDAP_SYNC_NO_BASEDN", State: healthcheck.StateFiring, Subject: "LdapSettings.BaseDN"},
|
||||
{Code: "LICENSE_EXPIRED", State: healthcheck.StateUnknown, Subject: expiresAtSubject},
|
||||
{Code: "LICENSE_EXPIRING", State: healthcheck.StateUnknown, Subject: expiresAtSubject},
|
||||
{Code: "METRICS_OFF", State: healthcheck.StateFiring, Subject: "MetricsSettings.Enable"},
|
||||
@@ -143,6 +146,7 @@ func TestReadGoldenPackets(t *testing.T) {
|
||||
{Code: "PUSH_ID_ONLY", State: healthcheck.StateFiring, Subject: "EmailSettings.PushNotificationContents"},
|
||||
{Code: "PUSH_TEST_PROXY", State: healthcheck.StateFiring, Subject: pushSubject},
|
||||
{Code: "SEATS_LIMIT_REACHED", State: healthcheck.StateUnknown, Subject: seatsSubject},
|
||||
{Code: "SESSION_EXTEND_OFF", State: healthcheck.StateFiring, Subject: "ServiceSettings.ExtendSessionLengthWithActivity"},
|
||||
{Code: "SITE_URL_HTTP", State: healthcheck.StateFiring, Subject: siteURLSubject},
|
||||
{Code: "VERSION_EOL", State: healthcheck.StateFiring, Subject: versionSubject},
|
||||
},
|
||||
@@ -159,6 +163,7 @@ func TestReadGoldenPackets(t *testing.T) {
|
||||
{Code: "LICENSE_EXPIRING", State: healthcheck.StateFiring, Subject: expiresAtSubject},
|
||||
{Code: "PUSH_BAD_SCHEME", State: healthcheck.StateFiring, Subject: pushSubject},
|
||||
{Code: "PUSH_ID_ONLY", State: healthcheck.StateFiring, Subject: "EmailSettings.PushNotificationContents"},
|
||||
{Code: "SAML_ENCRYPT_OFF", State: healthcheck.StateFiring, Subject: "SamlSettings.Encrypt"},
|
||||
{Code: "VERSION_EOL", State: healthcheck.StateFiring, Subject: versionSubject},
|
||||
},
|
||||
},
|
||||
@@ -330,6 +335,9 @@ func TestReadCloud(t *testing.T) {
|
||||
codes = append(codes, f.Code)
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"LDAP_PROBE_FAILED",
|
||||
"LDAP_RECOMMENDED_AT_SCALE",
|
||||
"LDAP_SYNC_NO_BASEDN",
|
||||
"PLUGIN_NOT_RUNNING",
|
||||
"PUSH_ID_ONLY",
|
||||
"PUSH_TEST_PROXY",
|
||||
|
||||
+5
-2
@@ -70,9 +70,12 @@ notifications:
|
||||
push:
|
||||
status: OK
|
||||
ldap:
|
||||
status: disabled
|
||||
status: OK
|
||||
server_name: unknown
|
||||
server_version: unknown
|
||||
saml:
|
||||
status: disabled
|
||||
provider_type: ADFS
|
||||
status: OK
|
||||
elastic:
|
||||
status: OK
|
||||
backend: opensearch
|
||||
|
||||
Vendored
+23
-2
@@ -10,7 +10,8 @@
|
||||
"TLSMinVer": "1.2",
|
||||
"Forward80To443": false,
|
||||
"EnableIncomingWebhooks": true,
|
||||
"EnableOutgoingWebhooks": true
|
||||
"EnableOutgoingWebhooks": true,
|
||||
"ExtendSessionLengthWithActivity": true
|
||||
},
|
||||
"TeamSettings": {
|
||||
"MaxNotificationsPerChannel": 1000
|
||||
@@ -64,7 +65,27 @@
|
||||
"BatchSize": 30000
|
||||
},
|
||||
"LdapSettings": {
|
||||
"EnableSync": true
|
||||
"Enable": false,
|
||||
"EnableSync": true,
|
||||
"LdapServer": "dc1.example.com",
|
||||
"LdapPort": 636,
|
||||
"ConnectionSecurity": "TLS",
|
||||
"BaseDN": "dc=example,dc=com",
|
||||
"IdAttribute": "objectGUID",
|
||||
"SyncIntervalMinutes": 60,
|
||||
"SkipCertificateVerification": false,
|
||||
"QueryTimeout": 60
|
||||
},
|
||||
"SamlSettings": {
|
||||
"Enable": true,
|
||||
"Verify": true,
|
||||
"Encrypt": false,
|
||||
"SignRequest": false,
|
||||
"IdpURL": "https://adfs.example.com/adfs/ls/",
|
||||
"IdpDescriptorURL": "https://adfs.example.com/adfs/services/trust",
|
||||
"IdpMetadataURL": "https://adfs.example.com/FederationMetadata/2007-06/FederationMetadata.xml",
|
||||
"SignatureAlgorithm": "RSAwithSHA256",
|
||||
"IdAttribute": "objectGUID"
|
||||
},
|
||||
"ClusterSettings": {
|
||||
"Enable": true,
|
||||
|
||||
+5
-2
@@ -71,9 +71,12 @@ notifications:
|
||||
push:
|
||||
status: OK
|
||||
ldap:
|
||||
status: disabled
|
||||
status: OK
|
||||
server_name: unknown
|
||||
server_version: unknown
|
||||
saml:
|
||||
status: disabled
|
||||
provider_type: ADFS
|
||||
status: OK
|
||||
elastic:
|
||||
status: OK
|
||||
backend: opensearch
|
||||
|
||||
Vendored
+23
-2
@@ -10,7 +10,8 @@
|
||||
"TLSMinVer": "1.2",
|
||||
"Forward80To443": false,
|
||||
"EnableIncomingWebhooks": true,
|
||||
"EnableOutgoingWebhooks": true
|
||||
"EnableOutgoingWebhooks": true,
|
||||
"ExtendSessionLengthWithActivity": true
|
||||
},
|
||||
"TeamSettings": {
|
||||
"MaxNotificationsPerChannel": 1000
|
||||
@@ -64,7 +65,27 @@
|
||||
"BatchSize": 30000
|
||||
},
|
||||
"LdapSettings": {
|
||||
"EnableSync": true
|
||||
"Enable": false,
|
||||
"EnableSync": true,
|
||||
"LdapServer": "dc1.example.com",
|
||||
"LdapPort": 636,
|
||||
"ConnectionSecurity": "TLS",
|
||||
"BaseDN": "dc=example,dc=com",
|
||||
"IdAttribute": "objectGUID",
|
||||
"SyncIntervalMinutes": 60,
|
||||
"SkipCertificateVerification": false,
|
||||
"QueryTimeout": 60
|
||||
},
|
||||
"SamlSettings": {
|
||||
"Enable": true,
|
||||
"Verify": true,
|
||||
"Encrypt": false,
|
||||
"SignRequest": false,
|
||||
"IdpURL": "https://adfs.example.com/adfs/ls/",
|
||||
"IdpDescriptorURL": "https://adfs.example.com/adfs/services/trust",
|
||||
"IdpMetadataURL": "https://adfs.example.com/FederationMetadata/2007-06/FederationMetadata.xml",
|
||||
"SignatureAlgorithm": "RSAwithSHA256",
|
||||
"IdAttribute": "objectGUID"
|
||||
},
|
||||
"ClusterSettings": {
|
||||
"Enable": true,
|
||||
|
||||
+5
-2
@@ -70,9 +70,12 @@ notifications:
|
||||
push:
|
||||
status: OK
|
||||
ldap:
|
||||
status: disabled
|
||||
status: OK
|
||||
server_name: unknown
|
||||
server_version: unknown
|
||||
saml:
|
||||
status: disabled
|
||||
provider_type: ADFS
|
||||
status: OK
|
||||
elastic:
|
||||
status: OK
|
||||
backend: opensearch
|
||||
|
||||
Vendored
+23
-2
@@ -10,7 +10,8 @@
|
||||
"TLSMinVer": "1.2",
|
||||
"Forward80To443": false,
|
||||
"EnableIncomingWebhooks": true,
|
||||
"EnableOutgoingWebhooks": true
|
||||
"EnableOutgoingWebhooks": true,
|
||||
"ExtendSessionLengthWithActivity": true
|
||||
},
|
||||
"TeamSettings": {
|
||||
"MaxNotificationsPerChannel": 1000
|
||||
@@ -64,7 +65,27 @@
|
||||
"BatchSize": 30000
|
||||
},
|
||||
"LdapSettings": {
|
||||
"EnableSync": true
|
||||
"Enable": false,
|
||||
"EnableSync": true,
|
||||
"LdapServer": "dc1.example.com",
|
||||
"LdapPort": 636,
|
||||
"ConnectionSecurity": "TLS",
|
||||
"BaseDN": "dc=example,dc=com",
|
||||
"IdAttribute": "objectGUID",
|
||||
"SyncIntervalMinutes": 60,
|
||||
"SkipCertificateVerification": false,
|
||||
"QueryTimeout": 60
|
||||
},
|
||||
"SamlSettings": {
|
||||
"Enable": true,
|
||||
"Verify": true,
|
||||
"Encrypt": false,
|
||||
"SignRequest": false,
|
||||
"IdpURL": "https://adfs.example.com/adfs/ls/",
|
||||
"IdpDescriptorURL": "https://adfs.example.com/adfs/services/trust",
|
||||
"IdpMetadataURL": "https://adfs.example.com/FederationMetadata/2007-06/FederationMetadata.xml",
|
||||
"SignatureAlgorithm": "RSAwithSHA256",
|
||||
"IdAttribute": "objectGUID"
|
||||
},
|
||||
"ClusterSettings": {
|
||||
"Enable": true,
|
||||
|
||||
+34
-2
@@ -10,7 +10,8 @@
|
||||
"TLSMinVer": "1.2",
|
||||
"Forward80To443": false,
|
||||
"EnableIncomingWebhooks": true,
|
||||
"EnableOutgoingWebhooks": true
|
||||
"EnableOutgoingWebhooks": true,
|
||||
"ExtendSessionLengthWithActivity": false
|
||||
},
|
||||
"TeamSettings": {
|
||||
"MaxNotificationsPerChannel": 1000
|
||||
@@ -64,7 +65,38 @@
|
||||
"BatchSize": 30000
|
||||
},
|
||||
"LdapSettings": {
|
||||
"EnableSync": true
|
||||
"Enable": false,
|
||||
"EnableSync": true,
|
||||
"LdapServer": "",
|
||||
"LdapPort": 389,
|
||||
"ConnectionSecurity": "",
|
||||
"BaseDN": "",
|
||||
"IdAttribute": "",
|
||||
"SyncIntervalMinutes": 60,
|
||||
"SkipCertificateVerification": false,
|
||||
"QueryTimeout": 60
|
||||
},
|
||||
"SamlSettings": {
|
||||
"Enable": false,
|
||||
"Verify": true,
|
||||
"Encrypt": true,
|
||||
"IdpURL": "",
|
||||
"IdpDescriptorURL": "",
|
||||
"IdpMetadataURL": "",
|
||||
"SignatureAlgorithm": "RSAwithSHA256",
|
||||
"IdAttribute": ""
|
||||
},
|
||||
"GitLabSettings": {
|
||||
"Enable": false
|
||||
},
|
||||
"GoogleSettings": {
|
||||
"Enable": false
|
||||
},
|
||||
"Office365Settings": {
|
||||
"Enable": false
|
||||
},
|
||||
"OpenIdSettings": {
|
||||
"Enable": false
|
||||
},
|
||||
"ClusterSettings": {
|
||||
"Enable": false,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Package auth holds the health rules for AD/LDAP, SAML and sessions.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
const (
|
||||
ldapConsolePath = "/admin_console/authentication/ldap"
|
||||
ldapDocsURL = "https://mattermost.com/pl/setup-ldap"
|
||||
samlConsolePath = "/admin_console/authentication/saml"
|
||||
samlDocsURL = "https://docs.mattermost.com/administration-guide/onboard/sso-saml.html"
|
||||
)
|
||||
|
||||
// gated resolves a rule whose gate is closed, so a condition that cannot bite never fires.
|
||||
func gated(gate func(*healthcheck.Snapshot) (bool, bool), check func(*healthcheck.Snapshot) healthcheck.Result) func(*healthcheck.Snapshot) []healthcheck.Result {
|
||||
return func(s *healthcheck.Snapshot) []healthcheck.Result {
|
||||
open, ok := gate(s)
|
||||
switch {
|
||||
case !ok:
|
||||
return []healthcheck.Result{unknownConfig()}
|
||||
case !open:
|
||||
return []healthcheck.Result{healthcheck.Resolved()}
|
||||
default:
|
||||
return []healthcheck.Result{check(s)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ldapEnabled matches the server's own test before probing LDAP: sync-only installs count.
|
||||
func ldapEnabled(s *healthcheck.Snapshot) (bool, bool) {
|
||||
enable, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.LdapSettings.Enable })
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
if enable {
|
||||
return true, true
|
||||
}
|
||||
|
||||
return ldapSyncEnabled(s)
|
||||
}
|
||||
|
||||
func ldapSyncEnabled(s *healthcheck.Snapshot) (bool, bool) {
|
||||
return s.ConfigBool(func(cfg *model.Config) *bool { return cfg.LdapSettings.EnableSync })
|
||||
}
|
||||
|
||||
func samlEnabled(s *healthcheck.Snapshot) (bool, bool) {
|
||||
return s.ConfigBool(func(cfg *model.Config) *bool { return cfg.SamlSettings.Enable })
|
||||
}
|
||||
|
||||
func unknownConfig() healthcheck.Result {
|
||||
return healthcheck.Unknown(healthcheck.ReasonConfigUnavailable)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var allRules = []healthcheck.Rule{
|
||||
ldapPortTLSMismatch,
|
||||
ldapPortPlainMismatch,
|
||||
ldapSkipCert,
|
||||
ldapQueryTimeoutLow,
|
||||
ldapSyncNoBaseDN,
|
||||
ldapRecommendedAtScale,
|
||||
ldapIDIsEmail,
|
||||
ldapSyncDutyCycle,
|
||||
samlSignatureSHA1,
|
||||
samlVerifyOff,
|
||||
samlEncryptOff,
|
||||
samlIDIsEmail,
|
||||
samlIDMutable,
|
||||
sessionExtendOff,
|
||||
ldapProbeFailed,
|
||||
samlProbeFailed,
|
||||
}
|
||||
|
||||
type want struct {
|
||||
state healthcheck.State
|
||||
messageID string
|
||||
details map[string]string
|
||||
value *float64
|
||||
}
|
||||
|
||||
var (
|
||||
resolved = want{state: healthcheck.StateResolved}
|
||||
configUnavailable = want{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonConfigUnavailable}
|
||||
)
|
||||
|
||||
func firing(messageID string, details map[string]string) want {
|
||||
return want{state: healthcheck.StateFiring, messageID: messageID, details: details}
|
||||
}
|
||||
|
||||
func (w want) withValue(v float64) want {
|
||||
w.value = &v
|
||||
return w
|
||||
}
|
||||
|
||||
// configSnapshot returns a snapshot over a defaulted config with edit applied.
|
||||
func configSnapshot(edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
cfg := &model.Config{}
|
||||
cfg.SetDefaults()
|
||||
if edit != nil {
|
||||
edit(cfg)
|
||||
}
|
||||
|
||||
return &healthcheck.Snapshot{
|
||||
Config: &model.SupportPacketConfig{Config: cfg},
|
||||
Sections: map[model.WorkspaceSection]error{model.SectionConfig: nil},
|
||||
}
|
||||
}
|
||||
|
||||
func assertResults(t *testing.T, snapshot *healthcheck.Snapshot, wants map[string]want) {
|
||||
t.Helper()
|
||||
|
||||
rules := map[string]healthcheck.Rule{}
|
||||
for _, rule := range allRules {
|
||||
rules[rule.Code] = rule
|
||||
}
|
||||
|
||||
for code, w := range wants {
|
||||
rule, ok := rules[code]
|
||||
require.True(t, ok, "unknown code %s", code)
|
||||
|
||||
results := rule.Eval(snapshot)
|
||||
require.Len(t, results, 1, rule.Code)
|
||||
assert.Equal(t, w.state, results[0].State, rule.Code)
|
||||
assert.Equal(t, w.messageID, results[0].MessageID, rule.Code)
|
||||
assert.Equal(t, w.details, results[0].Details, rule.Code)
|
||||
assert.Equal(t, w.value, results[0].Value, rule.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
registry := healthcheck.Builtin()
|
||||
require.NoError(t, registry.Validate())
|
||||
|
||||
severities := map[string]healthcheck.Severity{
|
||||
"LDAP_PORT_TLS_MISMATCH": healthcheck.SeverityWarning,
|
||||
"LDAP_PORT_PLAIN_MISMATCH": healthcheck.SeverityWarning,
|
||||
"LDAP_SKIP_CERT": healthcheck.SeverityWarning,
|
||||
"LDAP_QUERY_TIMEOUT_LOW": healthcheck.SeverityInfo,
|
||||
"LDAP_SYNC_NO_BASEDN": healthcheck.SeverityCritical,
|
||||
"LDAP_RECOMMENDED_AT_SCALE": healthcheck.SeverityInfo,
|
||||
"LDAP_ID_IS_EMAIL": healthcheck.SeverityWarning,
|
||||
"LDAP_SYNC_DUTY_CYCLE": healthcheck.SeverityWarning,
|
||||
"SAML_SIGNATURE_SHA1": healthcheck.SeverityInfo,
|
||||
"SAML_VERIFY_OFF": healthcheck.SeverityCritical,
|
||||
"SAML_ENCRYPT_OFF": healthcheck.SeverityInfo,
|
||||
"SAML_ID_IS_EMAIL": healthcheck.SeverityInfo,
|
||||
"SAML_ID_MUTABLE": healthcheck.SeverityInfo,
|
||||
"SESSION_EXTEND_OFF": healthcheck.SeverityInfo,
|
||||
"LDAP_PROBE_FAILED": healthcheck.SeverityWarning,
|
||||
"SAML_PROBE_FAILED": healthcheck.SeverityWarning,
|
||||
}
|
||||
require.Len(t, severities, len(allRules))
|
||||
|
||||
for _, rule := range allRules {
|
||||
registered, ok := registry.Get(rule.Code)
|
||||
require.True(t, ok, rule.Code)
|
||||
assert.Equal(t, severities[rule.Code], registered.Severity, rule.Code)
|
||||
assert.Equal(t, model.AreaAuth, registered.Area, rule.Code)
|
||||
assert.Equal(t, healthcheck.SurfaceProduct, registered.Surface, rule.Code)
|
||||
assert.NotNil(t, registered.Eval, rule.Code)
|
||||
assert.False(t, registered.IsNodeScoped(), rule.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wants := map[string]want{}
|
||||
for _, rule := range allRules {
|
||||
wants[rule.Code] = configUnavailable
|
||||
}
|
||||
|
||||
t.Run("no config section", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, &healthcheck.Snapshot{}, wants)
|
||||
})
|
||||
|
||||
t.Run("gate settings absent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, configSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings = model.LdapSettings{}
|
||||
cfg.SamlSettings = model.SamlSettings{}
|
||||
cfg.ServiceSettings.ExtendSessionLengthWithActivity = nil
|
||||
}), wants)
|
||||
})
|
||||
}
|
||||
|
||||
// everyConditionHolds sets every LDAP and SAML setting to a firing value, with both providers off.
|
||||
func everyConditionHolds(cfg *model.Config) {
|
||||
cfg.LdapSettings.LdapPort = new(389)
|
||||
cfg.LdapSettings.ConnectionSecurity = new(model.ConnSecurityTLS)
|
||||
cfg.LdapSettings.SkipCertificateVerification = new(true)
|
||||
cfg.LdapSettings.QueryTimeout = new(5)
|
||||
cfg.LdapSettings.SyncIntervalMinutes = new(5)
|
||||
cfg.LdapSettings.BaseDN = new("")
|
||||
cfg.LdapSettings.IdAttribute = new("mail")
|
||||
|
||||
cfg.SamlSettings.SignRequest = new(true)
|
||||
cfg.SamlSettings.SignatureAlgorithm = new(model.SamlSettingsSignatureAlgorithmSha1)
|
||||
cfg.SamlSettings.Verify = new(false)
|
||||
cfg.SamlSettings.Encrypt = new(false)
|
||||
cfg.SamlSettings.IdAttribute = new("")
|
||||
}
|
||||
|
||||
// gateSnapshot carries saturating ldap_sync jobs and failing probes, so every code fires once its gate opens.
|
||||
func gateSnapshot(edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
diag := &model.SupportPacketDiagnostics{}
|
||||
diag.LDAP.Status = model.StatusFail
|
||||
diag.SAML.Status = model.StatusFail
|
||||
|
||||
s := healthcheck.NewSnapshot([]*healthcheck.NodeSnapshot{{
|
||||
Hostname: "app-1.example.com",
|
||||
IsLeader: true,
|
||||
Diagnostics: &model.NodeDiagnostics{Diagnostics: diag},
|
||||
}})
|
||||
cs := configSnapshot(func(cfg *model.Config) {
|
||||
everyConditionHolds(cfg)
|
||||
edit(cfg)
|
||||
})
|
||||
s.Config, s.Sections = cs.Config, cs.Sections
|
||||
s.Stats = &model.SupportPacketStats{ActiveUsers: new(int64(500))}
|
||||
s.Jobs = &model.SupportPacketJobList{LDAPSyncJobs: []*model.Job{{Status: model.JobStatusSuccess, StartAt: 1_000_000, LastActivityAt: 1_000_000 + 5*60*1000}}}
|
||||
s.Sections[model.SectionStats] = nil
|
||||
s.Sections[model.SectionJobs] = nil
|
||||
return s
|
||||
}
|
||||
|
||||
func TestGates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ldapGated := []healthcheck.Rule{ldapPortTLSMismatch, ldapSkipCert, ldapQueryTimeoutLow, ldapIDIsEmail, ldapProbeFailed}
|
||||
syncGated := []healthcheck.Rule{ldapSyncNoBaseDN, ldapSyncDutyCycle}
|
||||
samlGated := []healthcheck.Rule{samlSignatureSHA1, samlVerifyOff, samlEncryptOff, samlIDIsEmail, samlProbeFailed}
|
||||
|
||||
stateOf := func(t *testing.T, s *healthcheck.Snapshot, rules []healthcheck.Rule) map[string]healthcheck.State {
|
||||
t.Helper()
|
||||
|
||||
states := map[string]healthcheck.State{}
|
||||
for _, rule := range rules {
|
||||
results := rule.Eval(s)
|
||||
require.Len(t, results, 1, rule.Code)
|
||||
states[rule.Code] = results[0].State
|
||||
}
|
||||
return states
|
||||
}
|
||||
all := func(rules []healthcheck.Rule, state healthcheck.State) map[string]healthcheck.State {
|
||||
states := map[string]healthcheck.State{}
|
||||
for _, rule := range rules {
|
||||
states[rule.Code] = state
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
t.Run("every gate closed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := gateSnapshot(func(cfg *model.Config) {})
|
||||
assert.Equal(t, all(ldapGated, healthcheck.StateResolved), stateOf(t, s, ldapGated))
|
||||
assert.Equal(t, all(syncGated, healthcheck.StateResolved), stateOf(t, s, syncGated))
|
||||
assert.Equal(t, all(samlGated, healthcheck.StateResolved), stateOf(t, s, samlGated))
|
||||
assert.Equal(t, healthcheck.StateFiring, ldapRecommendedAtScale.Eval(s)[0].State)
|
||||
})
|
||||
|
||||
t.Run("sync only opens the LDAP and sync gates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := gateSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(false)
|
||||
cfg.LdapSettings.EnableSync = new(true)
|
||||
})
|
||||
assert.Equal(t, all(ldapGated, healthcheck.StateFiring), stateOf(t, s, ldapGated))
|
||||
assert.Equal(t, all(syncGated, healthcheck.StateFiring), stateOf(t, s, syncGated))
|
||||
assert.Equal(t, all(samlGated, healthcheck.StateResolved), stateOf(t, s, samlGated))
|
||||
assertResults(t, s, map[string]want{
|
||||
"LDAP_SYNC_NO_BASEDN": firing("health.rule.ldap_sync_no_basedn.message", nil),
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("sign-in without sync keeps the sync gate closed despite stale jobs", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := gateSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(true)
|
||||
cfg.LdapSettings.EnableSync = new(false)
|
||||
})
|
||||
assert.Equal(t, all(ldapGated, healthcheck.StateFiring), stateOf(t, s, ldapGated))
|
||||
assert.Equal(t, all(syncGated, healthcheck.StateResolved), stateOf(t, s, syncGated))
|
||||
})
|
||||
|
||||
t.Run("SAML opens only the SAML gate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := gateSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = new(true)
|
||||
})
|
||||
assert.Equal(t, all(ldapGated, healthcheck.StateResolved), stateOf(t, s, ldapGated))
|
||||
assert.Equal(t, all(syncGated, healthcheck.StateResolved), stateOf(t, s, syncGated))
|
||||
assert.Equal(t, all(samlGated, healthcheck.StateFiring), stateOf(t, s, samlGated))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func init() {
|
||||
healthcheck.Register(
|
||||
ldapPortTLSMismatch,
|
||||
ldapPortPlainMismatch,
|
||||
ldapSkipCert,
|
||||
ldapQueryTimeoutLow,
|
||||
ldapSyncNoBaseDN,
|
||||
ldapRecommendedAtScale,
|
||||
ldapIDIsEmail,
|
||||
ldapSyncDutyCycle,
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
ldapPortSubject = "LdapSettings.LdapPort"
|
||||
|
||||
minLdapQueryTimeoutSeconds = 10
|
||||
ldapRecommendedUsers = 100
|
||||
maxDutyCycleIntervalMinutes = 30
|
||||
maxDutyCyclePct = 25
|
||||
)
|
||||
|
||||
var ldapIDEmailAttributes = []string{"mail", "email", "userprincipalname"}
|
||||
|
||||
var ldapPortTLSMismatch = healthcheck.Rule{
|
||||
Code: "LDAP_PORT_TLS_MISMATCH",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_port_tls_mismatch.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_port_tls_mismatch.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: ldapPortSubject,
|
||||
Eval: gated(ldapEnabled, evalLdapPortTLSMismatch),
|
||||
}
|
||||
|
||||
var ldapPortPlainMismatch = healthcheck.Rule{
|
||||
Code: "LDAP_PORT_PLAIN_MISMATCH",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_port_plain_mismatch.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_port_plain_mismatch.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: ldapPortSubject,
|
||||
Eval: gated(ldapEnabled, evalLdapPortPlainMismatch),
|
||||
}
|
||||
|
||||
var ldapSkipCert = healthcheck.Rule{
|
||||
Code: "LDAP_SKIP_CERT",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_skip_cert.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_skip_cert.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.SkipCertificateVerification",
|
||||
Eval: gated(ldapEnabled, evalLdapSkipCert),
|
||||
}
|
||||
|
||||
var ldapQueryTimeoutLow = healthcheck.Rule{
|
||||
Code: "LDAP_QUERY_TIMEOUT_LOW",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_query_timeout_low.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_query_timeout_low.remediation"),
|
||||
DocsURL: "https://mattermost.com/pl/configure-ad-ldap-query-timeout",
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.QueryTimeout",
|
||||
Eval: gated(ldapEnabled, evalLdapQueryTimeoutLow),
|
||||
}
|
||||
|
||||
var ldapSyncNoBaseDN = healthcheck.Rule{
|
||||
Code: "LDAP_SYNC_NO_BASEDN",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityCritical,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_sync_no_basedn.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_sync_no_basedn.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.BaseDN",
|
||||
Eval: gated(ldapSyncEnabled, evalLdapSyncNoBaseDN),
|
||||
}
|
||||
|
||||
var ldapRecommendedAtScale = healthcheck.Rule{
|
||||
Code: "LDAP_RECOMMENDED_AT_SCALE",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_recommended_at_scale.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_recommended_at_scale.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityThreshold,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.Enable",
|
||||
Eval: evalLdapRecommendedAtScale,
|
||||
}
|
||||
|
||||
var ldapIDIsEmail = healthcheck.Rule{
|
||||
Code: "LDAP_ID_IS_EMAIL",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_id_is_email.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_id_is_email.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.IdAttribute",
|
||||
Eval: gated(ldapEnabled, evalLdapIDIsEmail),
|
||||
}
|
||||
|
||||
var ldapSyncDutyCycle = healthcheck.Rule{
|
||||
Code: "LDAP_SYNC_DUTY_CYCLE",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_sync_duty_cycle.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_sync_duty_cycle.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityThreshold,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.SyncIntervalMinutes",
|
||||
Eval: gated(ldapSyncEnabled, evalLdapSyncDutyCycle),
|
||||
}
|
||||
|
||||
func ldapPortSecurity(s *healthcheck.Snapshot) (port int, security string, ok bool) {
|
||||
port, ok = s.ConfigInt(func(cfg *model.Config) *int { return cfg.LdapSettings.LdapPort })
|
||||
if !ok {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
security, ok = s.ConfigString(func(cfg *model.Config) *string { return cfg.LdapSettings.ConnectionSecurity })
|
||||
return port, security, ok
|
||||
}
|
||||
|
||||
func evalLdapPortTLSMismatch(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
port, security, ok := ldapPortSecurity(s)
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case port == 389 && security == model.ConnSecurityTLS:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_port_tls_mismatch.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalLdapPortPlainMismatch(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
port, security, ok := ldapPortSecurity(s)
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case port == 636 && (security == model.ConnSecurityNone || security == model.ConnSecurityStarttls):
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_port_plain_mismatch.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
// SkipCertificateVerification is inert without TLS or STARTTLS.
|
||||
func evalLdapSkipCert(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
security, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.LdapSettings.ConnectionSecurity })
|
||||
if !ok {
|
||||
return unknownConfig()
|
||||
}
|
||||
|
||||
skip, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.LdapSettings.SkipCertificateVerification })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case skip && security != model.ConnSecurityNone:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_skip_cert.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalLdapQueryTimeoutLow(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
timeout, ok := s.ConfigInt(func(cfg *model.Config) *int { return cfg.LdapSettings.QueryTimeout })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case timeout < minLdapQueryTimeoutSeconds:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_query_timeout_low.message")).WithDetail("timeout", strconv.Itoa(timeout))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
// Validation requires BaseDN when Enable is set, so this can only fire on a sync-only install.
|
||||
func evalLdapSyncNoBaseDN(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
baseDN, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.LdapSettings.BaseDN })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case baseDN == "":
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_sync_no_basedn.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalLdapIDIsEmail(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
attribute, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.LdapSettings.IdAttribute })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case slices.Contains(ldapIDEmailAttributes, strings.ToLower(attribute)):
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_id_is_email.message")).WithDetail("attribute", attribute)
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
// Identity gates come before stats so a packet without stats still resolves an SSO install.
|
||||
func evalLdapRecommendedAtScale(s *healthcheck.Snapshot) []healthcheck.Result {
|
||||
sso, ok := ssoEnabled(s)
|
||||
if !ok {
|
||||
return []healthcheck.Result{unknownConfig()}
|
||||
}
|
||||
if sso {
|
||||
return []healthcheck.Result{healthcheck.Resolved()}
|
||||
}
|
||||
|
||||
users, ok := s.Stat(func(stats *model.SupportPacketStats) *int64 { return stats.ActiveUsers })
|
||||
if !ok {
|
||||
return []healthcheck.Result{healthcheck.Unknown(healthcheck.ReasonStatsUnavailable)}
|
||||
}
|
||||
|
||||
result := healthcheck.Resolved()
|
||||
if users > ldapRecommendedUsers {
|
||||
result = healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_recommended_at_scale.message")).WithDetail("users", strconv.FormatInt(users, 10))
|
||||
}
|
||||
return []healthcheck.Result{result.WithValue(float64(users))}
|
||||
}
|
||||
|
||||
// ssoEnabled ignores LDAP sync, which signs no one in: sync-only users still use local passwords.
|
||||
func ssoEnabled(s *healthcheck.Snapshot) (bool, bool) {
|
||||
known := true
|
||||
for _, setting := range []func(*model.Config) *bool{
|
||||
func(cfg *model.Config) *bool { return cfg.LdapSettings.Enable },
|
||||
func(cfg *model.Config) *bool { return cfg.SamlSettings.Enable },
|
||||
func(cfg *model.Config) *bool { return cfg.GitLabSettings.Enable },
|
||||
func(cfg *model.Config) *bool { return cfg.GoogleSettings.Enable },
|
||||
func(cfg *model.Config) *bool { return cfg.Office365Settings.Enable },
|
||||
func(cfg *model.Config) *bool { return cfg.OpenIdSettings.Enable },
|
||||
} {
|
||||
enabled, ok := s.ConfigBool(setting)
|
||||
if enabled {
|
||||
return true, true
|
||||
}
|
||||
known = known && ok
|
||||
}
|
||||
return false, known
|
||||
}
|
||||
|
||||
func evalLdapSyncDutyCycle(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
interval, ok := s.ConfigInt(func(cfg *model.Config) *int { return cfg.LdapSettings.SyncIntervalMinutes })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case interval <= 0 || interval > maxDutyCycleIntervalMinutes:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
|
||||
jobs, ok := s.JobsFor(model.JobTypeLdapSync)
|
||||
if !ok {
|
||||
return healthcheck.Unknown(healthcheck.ReasonJobsUnavailable)
|
||||
}
|
||||
|
||||
var totalMillis, runs int64
|
||||
for _, job := range jobs {
|
||||
if job == nil || job.StartAt <= 0 || job.LastActivityAt <= job.StartAt {
|
||||
continue
|
||||
}
|
||||
// Running, canceled and failed runs stop the clock early, which would understate the duty cycle.
|
||||
if job.Status != model.JobStatusSuccess && job.Status != model.JobStatusWarning {
|
||||
continue
|
||||
}
|
||||
totalMillis += job.LastActivityAt - job.StartAt
|
||||
runs++
|
||||
}
|
||||
if runs == 0 {
|
||||
return healthcheck.Unknown(healthcheck.TranslationId("health.rule.ldap_sync_duty_cycle.message.no_runs"))
|
||||
}
|
||||
|
||||
avgSeconds := float64(totalMillis) / float64(runs) / 1000
|
||||
dutyPct := 100 * avgSeconds / float64(interval*60)
|
||||
|
||||
result := healthcheck.Resolved()
|
||||
if dutyPct >= maxDutyCyclePct {
|
||||
result = healthcheck.Firing(healthcheck.TranslationId("health.rule.ldap_sync_duty_cycle.message")).
|
||||
WithDetail("interval_minutes", strconv.Itoa(interval)).
|
||||
WithDetail("avg_run_seconds", strconv.FormatFloat(avgSeconds, 'f', 0, 64)).
|
||||
WithDetail("duty_pct", strconv.FormatFloat(dutyPct, 'f', 0, 64))
|
||||
}
|
||||
return result.WithValue(dutyPct)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func ldapSnapshot(edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
return configSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(true)
|
||||
cfg.LdapSettings.EnableSync = new(true)
|
||||
cfg.LdapSettings.BaseDN = new("dc=example,dc=com")
|
||||
cfg.LdapSettings.IdAttribute = new("objectGUID")
|
||||
edit(cfg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLdapPortRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
port := func(port int, security string) *healthcheck.Snapshot {
|
||||
return ldapSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.LdapPort = new(port)
|
||||
cfg.LdapSettings.ConnectionSecurity = new(security)
|
||||
})
|
||||
}
|
||||
tlsMismatch := firing("health.rule.ldap_port_tls_mismatch.message", nil)
|
||||
plainMismatch := firing("health.rule.ldap_port_plain_mismatch.message", nil)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want map[string]want
|
||||
}{
|
||||
{"389 with TLS", port(389, model.ConnSecurityTLS), map[string]want{"LDAP_PORT_TLS_MISMATCH": tlsMismatch, "LDAP_PORT_PLAIN_MISMATCH": resolved}},
|
||||
{"389 with STARTTLS", port(389, model.ConnSecurityStarttls), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": resolved}},
|
||||
{"389 without security", port(389, model.ConnSecurityNone), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": resolved}},
|
||||
{"636 with TLS", port(636, model.ConnSecurityTLS), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": resolved}},
|
||||
{"636 without security", port(636, model.ConnSecurityNone), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": plainMismatch}},
|
||||
{"636 with STARTTLS", port(636, model.ConnSecurityStarttls), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": plainMismatch}},
|
||||
{"nonstandard port with TLS", port(3269, model.ConnSecurityTLS), map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": resolved}},
|
||||
{
|
||||
name: "LDAP off",
|
||||
snapshot: configSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.LdapPort = new(636)
|
||||
cfg.LdapSettings.ConnectionSecurity = new(model.ConnSecurityNone)
|
||||
}),
|
||||
want: map[string]want{"LDAP_PORT_TLS_MISMATCH": resolved, "LDAP_PORT_PLAIN_MISMATCH": resolved},
|
||||
},
|
||||
{
|
||||
name: "port absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.LdapPort = nil }),
|
||||
want: map[string]want{"LDAP_PORT_TLS_MISMATCH": configUnavailable, "LDAP_PORT_PLAIN_MISMATCH": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "connection security absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.ConnectionSecurity = nil }),
|
||||
want: map[string]want{"LDAP_PORT_TLS_MISMATCH": configUnavailable, "LDAP_PORT_PLAIN_MISMATCH": configUnavailable},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapSettingRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want map[string]want
|
||||
}{
|
||||
{
|
||||
name: "defaults",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) {}),
|
||||
want: map[string]want{
|
||||
"LDAP_SKIP_CERT": resolved,
|
||||
"LDAP_QUERY_TIMEOUT_LOW": resolved,
|
||||
"LDAP_SYNC_NO_BASEDN": resolved,
|
||||
"LDAP_ID_IS_EMAIL": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip certificate verification over TLS",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.ConnectionSecurity = new(model.ConnSecurityTLS)
|
||||
cfg.LdapSettings.SkipCertificateVerification = new(true)
|
||||
}),
|
||||
want: map[string]want{"LDAP_SKIP_CERT": firing("health.rule.ldap_skip_cert.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "skip certificate verification over STARTTLS",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.ConnectionSecurity = new(model.ConnSecurityStarttls)
|
||||
cfg.LdapSettings.SkipCertificateVerification = new(true)
|
||||
}),
|
||||
want: map[string]want{"LDAP_SKIP_CERT": firing("health.rule.ldap_skip_cert.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "skip certificate verification without connection security",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.ConnectionSecurity = new(model.ConnSecurityNone)
|
||||
cfg.LdapSettings.SkipCertificateVerification = new(true)
|
||||
}),
|
||||
want: map[string]want{"LDAP_SKIP_CERT": resolved},
|
||||
},
|
||||
{
|
||||
name: "skip certificate verification absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.SkipCertificateVerification = nil }),
|
||||
want: map[string]want{"LDAP_SKIP_CERT": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "query timeout below 10s",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.QueryTimeout = new(9) }),
|
||||
want: map[string]want{"LDAP_QUERY_TIMEOUT_LOW": firing("health.rule.ldap_query_timeout_low.message", map[string]string{"timeout": "9"})},
|
||||
},
|
||||
{
|
||||
name: "query timeout at 10s",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.QueryTimeout = new(10) }),
|
||||
want: map[string]want{"LDAP_QUERY_TIMEOUT_LOW": resolved},
|
||||
},
|
||||
{
|
||||
name: "query timeout absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.QueryTimeout = nil }),
|
||||
want: map[string]want{"LDAP_QUERY_TIMEOUT_LOW": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "sync only with an empty BaseDN",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(false)
|
||||
cfg.LdapSettings.BaseDN = new("")
|
||||
}),
|
||||
want: map[string]want{"LDAP_SYNC_NO_BASEDN": firing("health.rule.ldap_sync_no_basedn.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "BaseDN absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.BaseDN = nil }),
|
||||
want: map[string]want{"LDAP_SYNC_NO_BASEDN": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute mail",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.IdAttribute = new("mail") }),
|
||||
want: map[string]want{"LDAP_ID_IS_EMAIL": firing("health.rule.ldap_id_is_email.message", map[string]string{"attribute": "mail"})},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute email in upper case",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.IdAttribute = new("EMAIL") }),
|
||||
want: map[string]want{"LDAP_ID_IS_EMAIL": firing("health.rule.ldap_id_is_email.message", map[string]string{"attribute": "EMAIL"})},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute userPrincipalName",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.IdAttribute = new("userPrincipalName") }),
|
||||
want: map[string]want{"LDAP_ID_IS_EMAIL": firing("health.rule.ldap_id_is_email.message", map[string]string{"attribute": "userPrincipalName"})},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute absent",
|
||||
snapshot: ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.IdAttribute = nil }),
|
||||
want: map[string]want{"LDAP_ID_IS_EMAIL": configUnavailable},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapRecommendedAtScale(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshot := func(users *int64, edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
s := configSnapshot(edit)
|
||||
if users != nil {
|
||||
s.Stats = &model.SupportPacketStats{ActiveUsers: users}
|
||||
s.Sections[model.SectionStats] = nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
firingAt := func(users string, value float64) want {
|
||||
return firing("health.rule.ldap_recommended_at_scale.message", map[string]string{"users": users}).withValue(value)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want want
|
||||
}{
|
||||
{"over 100 users without SSO", snapshot(new(int64(101)), nil), firingAt("101", 101)},
|
||||
{"100 users without SSO", snapshot(new(int64(100)), nil), resolved.withValue(100)},
|
||||
{"SAML enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.SamlSettings.Enable = new(true) }), resolved},
|
||||
{"LDAP sign-in enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.LdapSettings.Enable = new(true) }), resolved},
|
||||
{"LDAP sync only is not SSO", snapshot(new(int64(101)), func(cfg *model.Config) { cfg.LdapSettings.EnableSync = new(true) }), firingAt("101", 101)},
|
||||
{"GitLab enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.GitLabSettings.Enable = new(true) }), resolved},
|
||||
{"Google enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.GoogleSettings.Enable = new(true) }), resolved},
|
||||
{"Office 365 enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.Office365Settings.Enable = new(true) }), resolved},
|
||||
{"OpenID enabled and stats absent", snapshot(nil, func(cfg *model.Config) { cfg.OpenIdSettings.Enable = new(true) }), resolved},
|
||||
{"no SSO and stats absent", snapshot(nil, nil), want{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonStatsUnavailable}},
|
||||
{
|
||||
name: "user count query failed",
|
||||
snapshot: func() *healthcheck.Snapshot {
|
||||
s := snapshot(nil, nil)
|
||||
s.Stats = &model.SupportPacketStats{}
|
||||
s.Sections[model.SectionStats] = nil
|
||||
return s
|
||||
}(),
|
||||
want: want{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonStatsUnavailable},
|
||||
},
|
||||
{"SAML setting absent", snapshot(new(int64(500)), func(cfg *model.Config) { cfg.SamlSettings.Enable = nil }), configUnavailable},
|
||||
{"OpenID enabled and GitLab setting absent", snapshot(new(int64(500)), func(cfg *model.Config) {
|
||||
cfg.GitLabSettings.Enable = nil
|
||||
cfg.OpenIdSettings.Enable = new(true)
|
||||
}), resolved},
|
||||
{"OpenID setting absent", snapshot(new(int64(500)), func(cfg *model.Config) { cfg.OpenIdSettings.Enable = nil }), configUnavailable},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, map[string]want{"LDAP_RECOMMENDED_AT_SCALE": tc.want})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapSyncDutyCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const start = int64(1_758_000_000_000)
|
||||
run := func(seconds int64) *model.Job {
|
||||
return &model.Job{Type: model.JobTypeLdapSync, Status: model.JobStatusSuccess, StartAt: start, LastActivityAt: start + seconds*1000}
|
||||
}
|
||||
withStatus := func(job *model.Job, status string) *model.Job {
|
||||
job.Status = status
|
||||
return job
|
||||
}
|
||||
snapshot := func(interval int, jobs []*model.Job) *healthcheck.Snapshot {
|
||||
s := ldapSnapshot(func(cfg *model.Config) { cfg.LdapSettings.SyncIntervalMinutes = new(interval) })
|
||||
if jobs != nil {
|
||||
s.Jobs = &model.SupportPacketJobList{LDAPSyncJobs: jobs}
|
||||
s.Sections[model.SectionJobs] = nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
firingAt := func(interval, avg, duty string, value float64) want {
|
||||
return firing("health.rule.ldap_sync_duty_cycle.message", map[string]string{
|
||||
"interval_minutes": interval,
|
||||
"avg_run_seconds": avg,
|
||||
"duty_pct": duty,
|
||||
}).withValue(value)
|
||||
}
|
||||
noRuns := want{state: healthcheck.StateUnknown, messageID: "health.rule.ldap_sync_duty_cycle.message.no_runs"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want want
|
||||
}{
|
||||
{"run takes 25% of the interval", snapshot(10, []*model.Job{run(150)}), firingAt("10", "150", "25", 25)},
|
||||
{"run takes under 25% of the interval", snapshot(10, []*model.Job{run(120)}), resolved.withValue(20)},
|
||||
{"averages the runs", snapshot(10, []*model.Job{run(60), run(240)}), firingAt("10", "150", "25", 25)},
|
||||
{"30 minute interval is checked", snapshot(30, []*model.Job{run(900)}), firingAt("30", "900", "50", 50)},
|
||||
{"interval over 30 minutes resolves without jobs", snapshot(31, nil), resolved},
|
||||
{
|
||||
name: "runs without a positive duration are skipped",
|
||||
snapshot: snapshot(10, []*model.Job{
|
||||
run(0),
|
||||
{Type: model.JobTypeLdapSync, Status: model.JobStatusPending, LastActivityAt: start},
|
||||
nil,
|
||||
run(300),
|
||||
}),
|
||||
want: firingAt("10", "300", "50", 50),
|
||||
},
|
||||
{
|
||||
name: "only finished runs count",
|
||||
snapshot: snapshot(10, []*model.Job{
|
||||
withStatus(run(60), model.JobStatusInProgress),
|
||||
withStatus(run(60), model.JobStatusCanceled),
|
||||
withStatus(run(30), model.JobStatusError),
|
||||
withStatus(run(150), model.JobStatusSuccess),
|
||||
withStatus(run(150), model.JobStatusWarning),
|
||||
}),
|
||||
want: firingAt("10", "150", "25", 25),
|
||||
},
|
||||
{"jobs absent", snapshot(10, nil), want{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonJobsUnavailable}},
|
||||
{"no runs", snapshot(10, []*model.Job{}), noRuns},
|
||||
{"only pending runs", snapshot(10, []*model.Job{{Type: model.JobTypeLdapSync, Status: model.JobStatusPending}}), noRuns},
|
||||
{
|
||||
name: "interval absent",
|
||||
snapshot: func() *healthcheck.Snapshot {
|
||||
s := snapshot(10, []*model.Job{run(300)})
|
||||
s.Config.LdapSettings.SyncIntervalMinutes = nil
|
||||
return s
|
||||
}(),
|
||||
want: configUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, map[string]want{"LDAP_SYNC_DUTY_CYCLE": tc.want})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func init() {
|
||||
healthcheck.Register(ldapProbeFailed, samlProbeFailed)
|
||||
}
|
||||
|
||||
var ldapProbeFailed = healthcheck.Rule{
|
||||
Code: "LDAP_PROBE_FAILED",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.ldap_probe_failed.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.ldap_probe_failed.remediation"),
|
||||
DocsURL: ldapDocsURL,
|
||||
ConsolePath: ldapConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityProbe,
|
||||
AppliesToCloud: true,
|
||||
Subject: "LdapSettings.LdapServer",
|
||||
Eval: gated(ldapEnabled, evalLdapProbeFailed),
|
||||
}
|
||||
|
||||
var samlProbeFailed = healthcheck.Rule{
|
||||
Code: "SAML_PROBE_FAILED",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityWarning,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_probe_failed.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_probe_failed.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityProbe,
|
||||
AppliesToCloud: true,
|
||||
Subject: "SamlSettings.IdpCertificateFile",
|
||||
Eval: gated(samlEnabled, evalSamlProbeFailed),
|
||||
}
|
||||
|
||||
// leaderProbe resolves only on OK: an empty or disabled status means the leader never ran the probe.
|
||||
func leaderProbe(s *healthcheck.Snapshot, probe func(*model.SupportPacketDiagnostics) (status, probeErr string), firingID string) healthcheck.Result {
|
||||
leader, _ := s.Leader()
|
||||
// Probe section errors are ignored: the collector sets them only when LDAP vendor info fails after a passing test.
|
||||
diag, ok := leader.Diag()
|
||||
if !ok {
|
||||
return healthcheck.Unknown(healthcheck.ReasonDiagnosticsUnavailable)
|
||||
}
|
||||
|
||||
switch status, probeErr := probe(diag); status {
|
||||
case model.StatusOk:
|
||||
return healthcheck.Resolved()
|
||||
case model.StatusFail:
|
||||
return healthcheck.Firing(firingID).WithDetail("error", probeErr)
|
||||
default:
|
||||
return healthcheck.Unknown(healthcheck.ReasonDiagnosticsUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func evalLdapProbeFailed(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
return leaderProbe(s, func(diag *model.SupportPacketDiagnostics) (string, string) {
|
||||
return diag.LDAP.Status, diag.LDAP.Error
|
||||
}, healthcheck.TranslationId("health.rule.ldap_probe_failed.message"))
|
||||
}
|
||||
|
||||
func evalSamlProbeFailed(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
return leaderProbe(s, func(diag *model.SupportPacketDiagnostics) (string, string) {
|
||||
return diag.SAML.Status, diag.SAML.Error
|
||||
}, healthcheck.TranslationId("health.rule.saml_probe_failed.message"))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
// probeSnapshot enables LDAP sign-in and SAML over the given nodes.
|
||||
func probeSnapshot(nodes []*healthcheck.NodeSnapshot, edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
s := healthcheck.NewSnapshot(nodes)
|
||||
cs := configSnapshot(func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(true)
|
||||
cfg.SamlSettings.Enable = new(true)
|
||||
if edit != nil {
|
||||
edit(cfg)
|
||||
}
|
||||
})
|
||||
s.Config, s.Sections = cs.Config, cs.Sections
|
||||
return s
|
||||
}
|
||||
|
||||
func leaderWith(status, probeErr string) []*healthcheck.NodeSnapshot {
|
||||
diag := &model.SupportPacketDiagnostics{}
|
||||
diag.LDAP.Status = status
|
||||
diag.LDAP.Error = probeErr
|
||||
diag.SAML.Status = status
|
||||
diag.SAML.Error = probeErr
|
||||
|
||||
return []*healthcheck.NodeSnapshot{
|
||||
{Hostname: "app-1.example.com"},
|
||||
{Hostname: "app-2.example.com", IsLeader: true, Diagnostics: &model.NodeDiagnostics{Diagnostics: diag}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
diagnosticsUnavailable := want{state: healthcheck.StateUnknown, messageID: healthcheck.ReasonDiagnosticsUnavailable}
|
||||
details := map[string]string{"error": "connection refused"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want map[string]want
|
||||
}{
|
||||
{
|
||||
name: "fail",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusFail, "connection refused"), nil),
|
||||
want: map[string]want{
|
||||
"LDAP_PROBE_FAILED": firing("health.rule.ldap_probe_failed.message", details),
|
||||
"SAML_PROBE_FAILED": firing("health.rule.saml_probe_failed.message", details),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusOk, ""), nil),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": resolved, "SAML_PROBE_FAILED": resolved},
|
||||
},
|
||||
{
|
||||
name: "disabled although the gate is open",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusDisabled, ""), nil),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": diagnosticsUnavailable, "SAML_PROBE_FAILED": diagnosticsUnavailable},
|
||||
},
|
||||
{
|
||||
name: "empty status",
|
||||
snapshot: probeSnapshot(leaderWith("", ""), nil),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": diagnosticsUnavailable, "SAML_PROBE_FAILED": diagnosticsUnavailable},
|
||||
},
|
||||
{
|
||||
name: "leader without diagnostics",
|
||||
snapshot: probeSnapshot([]*healthcheck.NodeSnapshot{{Hostname: "app-1.example.com", IsLeader: true}}, nil),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": diagnosticsUnavailable, "SAML_PROBE_FAILED": diagnosticsUnavailable},
|
||||
},
|
||||
{
|
||||
name: "no leader",
|
||||
snapshot: probeSnapshot(nil, nil),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": diagnosticsUnavailable, "SAML_PROBE_FAILED": diagnosticsUnavailable},
|
||||
},
|
||||
{
|
||||
name: "expired IdP certificate",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusFail, "SAML IdP certificate expired on 2026-01-01T00:00:00Z"), nil),
|
||||
want: map[string]want{
|
||||
"SAML_PROBE_FAILED": firing("health.rule.saml_probe_failed.message", map[string]string{"error": "SAML IdP certificate expired on 2026-01-01T00:00:00Z"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SAML enable absent",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusFail, ""), func(cfg *model.Config) { cfg.SamlSettings.Enable = nil }),
|
||||
want: map[string]want{"SAML_PROBE_FAILED": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "both disabled with failing probes",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusFail, "connection refused"), func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(false)
|
||||
cfg.SamlSettings.Enable = new(false)
|
||||
}),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": resolved, "SAML_PROBE_FAILED": resolved},
|
||||
},
|
||||
{
|
||||
name: "LDAP sync only probes LDAP",
|
||||
snapshot: probeSnapshot(leaderWith(model.StatusFail, "connection refused"), func(cfg *model.Config) {
|
||||
cfg.LdapSettings.Enable = new(false)
|
||||
cfg.LdapSettings.EnableSync = new(true)
|
||||
}),
|
||||
want: map[string]want{"LDAP_PROBE_FAILED": firing("health.rule.ldap_probe_failed.message", details)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func init() {
|
||||
healthcheck.Register(samlSignatureSHA1, samlVerifyOff, samlEncryptOff, samlIDIsEmail, samlIDMutable)
|
||||
}
|
||||
|
||||
const samlIDSubject = "SamlSettings.IdAttribute"
|
||||
|
||||
var (
|
||||
// An empty IdAttribute keys SAML accounts by email.
|
||||
samlIDEmailAttributes = []string{"", "email", "mail", "emailaddress"}
|
||||
// Entra's .../identity/claims/name claim carries the UPN.
|
||||
samlIDMutableAttributes = []string{"username", "uid", "samaccountname", "upn", "userprincipalname", "name"}
|
||||
)
|
||||
|
||||
var samlSignatureSHA1 = healthcheck.Rule{
|
||||
Code: "SAML_SIGNATURE_SHA1",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_signature_sha1.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_signature_sha1.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "SamlSettings.SignatureAlgorithm",
|
||||
Eval: gated(samlEnabled, evalSamlSignatureSHA1),
|
||||
}
|
||||
|
||||
var samlVerifyOff = healthcheck.Rule{
|
||||
Code: "SAML_VERIFY_OFF",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityCritical,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_verify_off.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_verify_off.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "SamlSettings.Verify",
|
||||
Eval: gated(samlEnabled, evalSamlVerifyOff),
|
||||
}
|
||||
|
||||
var samlEncryptOff = healthcheck.Rule{
|
||||
Code: "SAML_ENCRYPT_OFF",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_encrypt_off.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_encrypt_off.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: "SamlSettings.Encrypt",
|
||||
Eval: gated(samlEnabled, evalSamlEncryptOff),
|
||||
}
|
||||
|
||||
var samlIDIsEmail = healthcheck.Rule{
|
||||
Code: "SAML_ID_IS_EMAIL",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_id_is_email.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_id_is_email.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: samlIDSubject,
|
||||
Eval: gated(samlEnabled, evalSamlIDIsEmail),
|
||||
}
|
||||
|
||||
var samlIDMutable = healthcheck.Rule{
|
||||
Code: "SAML_ID_MUTABLE",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.saml_id_mutable.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.saml_id_mutable.remediation"),
|
||||
DocsURL: samlDocsURL,
|
||||
ConsolePath: samlConsolePath,
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
AppliesToCloud: true,
|
||||
Subject: samlIDSubject,
|
||||
Eval: gated(samlEnabled, evalSamlIDMutable),
|
||||
}
|
||||
|
||||
// SignatureAlgorithm only signs requests, so it is inert while SignRequest is off.
|
||||
func evalSamlSignatureSHA1(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
sign, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.SamlSettings.SignRequest })
|
||||
if !ok {
|
||||
return unknownConfig()
|
||||
}
|
||||
if !sign {
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
|
||||
algorithm, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.SamlSettings.SignatureAlgorithm })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case algorithm == model.SamlSettingsSignatureAlgorithmSha1:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.saml_signature_sha1.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalSamlVerifyOff(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
verify, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.SamlSettings.Verify })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case !verify:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.saml_verify_off.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalSamlEncryptOff(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
encrypt, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.SamlSettings.Encrypt })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case !encrypt:
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.saml_encrypt_off.message"))
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
// samlAttributeName reduces a claim URI such as .../identity/claims/emailaddress to its last segment.
|
||||
func samlAttributeName(attribute string) string {
|
||||
return strings.ToLower(attribute[strings.LastIndex(attribute, "/")+1:])
|
||||
}
|
||||
|
||||
func evalSamlIDIsEmail(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
attribute, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.SamlSettings.IdAttribute })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case slices.Contains(samlIDEmailAttributes, samlAttributeName(attribute)):
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.saml_id_is_email.message")).WithDetail("attribute", attribute)
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
|
||||
func evalSamlIDMutable(s *healthcheck.Snapshot) healthcheck.Result {
|
||||
attribute, ok := s.ConfigString(func(cfg *model.Config) *string { return cfg.SamlSettings.IdAttribute })
|
||||
switch {
|
||||
case !ok:
|
||||
return unknownConfig()
|
||||
case slices.Contains(samlIDMutableAttributes, samlAttributeName(attribute)):
|
||||
return healthcheck.Firing(healthcheck.TranslationId("health.rule.saml_id_mutable.message")).WithDetail("attribute", attribute)
|
||||
default:
|
||||
return healthcheck.Resolved()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func samlSnapshot(edit func(cfg *model.Config)) *healthcheck.Snapshot {
|
||||
return configSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Enable = new(true)
|
||||
cfg.SamlSettings.IdAttribute = new("objectGUID")
|
||||
edit(cfg)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSamlRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
emailClaimURI = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
|
||||
nameClaimURI = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
|
||||
)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want map[string]want
|
||||
}{
|
||||
{
|
||||
name: "defaults",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) {}),
|
||||
want: map[string]want{
|
||||
"SAML_SIGNATURE_SHA1": resolved,
|
||||
"SAML_VERIFY_OFF": resolved,
|
||||
"SAML_ENCRYPT_OFF": resolved,
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SHA-1 signatures",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.SignRequest = new(true)
|
||||
cfg.SamlSettings.SignatureAlgorithm = new(model.SamlSettingsSignatureAlgorithmSha1)
|
||||
}),
|
||||
want: map[string]want{"SAML_SIGNATURE_SHA1": firing("health.rule.saml_signature_sha1.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "SHA-1 with unsigned requests",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.SignRequest = new(false)
|
||||
cfg.SamlSettings.SignatureAlgorithm = new(model.SamlSettingsSignatureAlgorithmSha1)
|
||||
}),
|
||||
want: map[string]want{"SAML_SIGNATURE_SHA1": resolved},
|
||||
},
|
||||
{
|
||||
name: "SHA-512 signatures",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.SignRequest = new(true)
|
||||
cfg.SamlSettings.SignatureAlgorithm = new(model.SamlSettingsSignatureAlgorithmSha512)
|
||||
}),
|
||||
want: map[string]want{"SAML_SIGNATURE_SHA1": resolved},
|
||||
},
|
||||
{
|
||||
name: "sign request absent",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.SignRequest = nil }),
|
||||
want: map[string]want{"SAML_SIGNATURE_SHA1": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "signature algorithm absent",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.SignRequest = new(true)
|
||||
cfg.SamlSettings.SignatureAlgorithm = nil
|
||||
}),
|
||||
want: map[string]want{"SAML_SIGNATURE_SHA1": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "verification off",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.Verify = new(false) }),
|
||||
want: map[string]want{"SAML_VERIFY_OFF": firing("health.rule.saml_verify_off.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "verify absent",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.Verify = nil }),
|
||||
want: map[string]want{"SAML_VERIFY_OFF": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "encryption off",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.Encrypt = new(false) }),
|
||||
want: map[string]want{"SAML_ENCRYPT_OFF": firing("health.rule.saml_encrypt_off.message", nil)},
|
||||
},
|
||||
{
|
||||
name: "encrypt absent",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.Encrypt = nil }),
|
||||
want: map[string]want{"SAML_ENCRYPT_OFF": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "empty IdAttribute keys by email",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": firing("health.rule.saml_id_is_email.message", map[string]string{"attribute": ""}),
|
||||
"SAML_ID_MUTABLE": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute EmailAddress",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("EmailAddress") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": firing("health.rule.saml_id_is_email.message", map[string]string{"attribute": "EmailAddress"}),
|
||||
"SAML_ID_MUTABLE": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute email claim URI",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new(emailClaimURI) }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": firing("health.rule.saml_id_is_email.message", map[string]string{"attribute": emailClaimURI}),
|
||||
"SAML_ID_MUTABLE": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute mail",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("mail") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": firing("health.rule.saml_id_is_email.message", map[string]string{"attribute": "mail"}),
|
||||
"SAML_ID_MUTABLE": resolved,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute sAMAccountName",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("sAMAccountName") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": firing("health.rule.saml_id_mutable.message", map[string]string{"attribute": "sAMAccountName"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute username",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("Username") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": firing("health.rule.saml_id_mutable.message", map[string]string{"attribute": "Username"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute uid",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("uid") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": firing("health.rule.saml_id_mutable.message", map[string]string{"attribute": "uid"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute userPrincipalName",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new("userPrincipalName") }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": firing("health.rule.saml_id_mutable.message", map[string]string{"attribute": "userPrincipalName"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute Entra name claim URI",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = new(nameClaimURI) }),
|
||||
want: map[string]want{
|
||||
"SAML_ID_IS_EMAIL": resolved,
|
||||
"SAML_ID_MUTABLE": firing("health.rule.saml_id_mutable.message", map[string]string{"attribute": nameClaimURI}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IdAttribute absent",
|
||||
snapshot: samlSnapshot(func(cfg *model.Config) { cfg.SamlSettings.IdAttribute = nil }),
|
||||
want: map[string]want{"SAML_ID_IS_EMAIL": configUnavailable, "SAML_ID_MUTABLE": configUnavailable},
|
||||
},
|
||||
{
|
||||
name: "SAML off",
|
||||
snapshot: configSnapshot(func(cfg *model.Config) {
|
||||
cfg.SamlSettings.Verify = new(false)
|
||||
cfg.SamlSettings.IdAttribute = new("uid")
|
||||
}),
|
||||
want: map[string]want{"SAML_VERIFY_OFF": resolved, "SAML_ID_IS_EMAIL": resolved, "SAML_ID_MUTABLE": resolved},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func init() {
|
||||
healthcheck.Register(sessionExtendOff)
|
||||
}
|
||||
|
||||
// Only upgraded configs default this to false, so the rule mostly finds long-lived installs.
|
||||
var sessionExtendOff = healthcheck.Rule{
|
||||
Code: "SESSION_EXTEND_OFF",
|
||||
Area: model.AreaAuth,
|
||||
Severity: healthcheck.SeverityInfo,
|
||||
RuleText: model.RuleText{
|
||||
TitleID: healthcheck.TranslationId("health.rule.session_extend_off.title"),
|
||||
RemediationID: healthcheck.TranslationId("health.rule.session_extend_off.remediation"),
|
||||
DocsURL: "https://mattermost.com/pl/configure-session-lengths",
|
||||
ConsolePath: "/admin_console/environment/session_lengths",
|
||||
},
|
||||
Surface: healthcheck.SurfaceProduct,
|
||||
Volatility: healthcheck.VolatilityStable,
|
||||
Subject: "ServiceSettings.ExtendSessionLengthWithActivity",
|
||||
Eval: evalSessionExtendOff,
|
||||
}
|
||||
|
||||
func evalSessionExtendOff(s *healthcheck.Snapshot) []healthcheck.Result {
|
||||
extend, ok := s.ConfigBool(func(cfg *model.Config) *bool { return cfg.ServiceSettings.ExtendSessionLengthWithActivity })
|
||||
switch {
|
||||
case !ok:
|
||||
return []healthcheck.Result{unknownConfig()}
|
||||
case !extend:
|
||||
return []healthcheck.Result{healthcheck.Firing(healthcheck.TranslationId("health.rule.session_extend_off.message"))}
|
||||
default:
|
||||
return []healthcheck.Result{healthcheck.Resolved()}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck"
|
||||
)
|
||||
|
||||
func TestSessionExtendOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extend := func(value *bool) *healthcheck.Snapshot {
|
||||
return configSnapshot(func(cfg *model.Config) { cfg.ServiceSettings.ExtendSessionLengthWithActivity = value })
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot *healthcheck.Snapshot
|
||||
want want
|
||||
}{
|
||||
{"off", extend(new(false)), firing("health.rule.session_extend_off.message", nil)},
|
||||
{"on", extend(new(true)), resolved},
|
||||
{"absent", extend(nil), configUnavailable},
|
||||
{"config section absent", &healthcheck.Snapshot{}, configUnavailable},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assertResults(t, tc.snapshot, map[string]want{"SESSION_EXTEND_OFF": tc.want})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
_ "github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck/rules/auth"
|
||||
_ "github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck/rules/cluster"
|
||||
_ "github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck/rules/compliance"
|
||||
_ "github.com/mattermost/mattermost/server/v8/platform/shared/healthcheck/rules/database"
|
||||
|
||||
@@ -56,10 +56,25 @@ func TestBuiltinRulesOnCloud(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, []string{
|
||||
"LDAP_ID_IS_EMAIL",
|
||||
"LDAP_PORT_PLAIN_MISMATCH",
|
||||
"LDAP_PORT_TLS_MISMATCH",
|
||||
"LDAP_PROBE_FAILED",
|
||||
"LDAP_QUERY_TIMEOUT_LOW",
|
||||
"LDAP_RECOMMENDED_AT_SCALE",
|
||||
"LDAP_SKIP_CERT",
|
||||
"LDAP_SYNC_DUTY_CYCLE",
|
||||
"LDAP_SYNC_NO_BASEDN",
|
||||
"PLUGIN_NOT_RUNNING",
|
||||
"PUSH_BAD_SCHEME",
|
||||
"PUSH_EMPTY_URL",
|
||||
"PUSH_ID_ONLY",
|
||||
"PUSH_TEST_PROXY",
|
||||
"SAML_ENCRYPT_OFF",
|
||||
"SAML_ID_IS_EMAIL",
|
||||
"SAML_ID_MUTABLE",
|
||||
"SAML_PROBE_FAILED",
|
||||
"SAML_SIGNATURE_SHA1",
|
||||
"SAML_VERIFY_OFF",
|
||||
}, cloud)
|
||||
}
|
||||
Reference in new issue
Block a user