mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
2117b86ea4
SSO operators can hold permitted non-administrative write capabilities. The guide now checks the actual instance-administration boundary, without instructing a settings mutation or changing roles. Change-source: pulse-maintainer
173 lines
9.1 KiB
Python
173 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Keep OIDC help honest about existing session and revocation boundaries.
|
|
|
|
Documentation/source consistency only: this does not exercise an IdP, browser
|
|
session, token exchange or installed access removal.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
GUIDE = ROOT / "docs/OIDC.md"
|
|
|
|
|
|
def guide_section(start, end):
|
|
text = GUIDE.read_text(encoding="utf-8")
|
|
return " ".join(text.split(start, 1)[1].split(end, 1)[0].split())
|
|
|
|
|
|
def assert_callback_setup(case, guide):
|
|
setup = " ".join(guide.split("## 🚀 Quick Start", 1)[1].split("## ⚙️ Configuration", 1)[0].split())
|
|
markers = ("Keep a working, independent administrator login", "Prepare the IdP application",
|
|
"Configure access before saving", "Save, then register the actual callback",
|
|
"Verify without replacing the working login", "Only after verifying SSO")
|
|
positions = []
|
|
for marker in markers:
|
|
case.assertIn(marker, setup)
|
|
positions.append(setup.index(marker))
|
|
case.assertEqual(positions, sorted(positions))
|
|
for marker in ("Show access restrictions & role mapping", "New UI providers are enabled on save",
|
|
"do not register a guessed ID", "not the callback, user claims",
|
|
"an intended non-admin cannot use administrator controls",
|
|
"does not create or restore local credentials"):
|
|
case.assertIn(marker, setup)
|
|
|
|
|
|
def assert_group_claim_advice(case, guide):
|
|
case.assertNotIn("Requires the `groups` scope/claim", guide)
|
|
case.assertNotIn("Ensure your IdP includes the `groups` scope", guide)
|
|
for marker in ("a `groups` scope is not universally supported", "not a scope name",
|
|
"Entra ID has no `groups` scope", "alone neither supplies group membership"):
|
|
case.assertIn(marker, " ".join(guide.split()))
|
|
|
|
|
|
class OIDCSessionDocsTest(unittest.TestCase):
|
|
def test_setup_orders_callback_and_access_before_cutover(self):
|
|
assert_callback_setup(self, GUIDE.read_text())
|
|
|
|
def test_generic_group_advice_does_not_break_entra(self):
|
|
assert_group_claim_advice(self, GUIDE.read_text())
|
|
|
|
def test_provider_examples_copy_the_saved_callback(self):
|
|
text = guide_section("## 📚 Provider Examples", "## 🔧 Troubleshooting")
|
|
self.assertNotIn("Create the provider in Pulse first", text)
|
|
self.assertNotIn("<provider-id>", text)
|
|
self.assertIn("Prepare the Entra app registration and client credentials first", text)
|
|
self.assertIn("Complete the redirect registration before signing in", text)
|
|
self.assertIn("secret value from step 2", text)
|
|
self.assertIn("Do **not** add `groups`", text)
|
|
self.assertIn("compatibility path", text)
|
|
|
|
def test_setup_matches_existing_form_and_discovery_test(self):
|
|
panel = (ROOT / "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx").read_text()
|
|
self.assertIn("URL is generated when the provider is saved", panel)
|
|
self.assertIn("access restrictions & role mapping", panel)
|
|
self.assertIn('label="Group Role Mappings"', panel)
|
|
model = (ROOT / "frontend-modern/src/components/Settings/ssoProvidersModel.ts").read_text()
|
|
defaults = model.split("export const createEmptyProviderForm", 1)[1].split("});", 1)[0]
|
|
self.assertIn("id: ''", defaults)
|
|
self.assertIn("enabled: true", defaults)
|
|
self.assertIn("oidcScopes: 'openid profile email'", defaults)
|
|
handlers = (ROOT / "internal/api/identity_sso_handlers.go").read_text()
|
|
create = handlers.split("func (r *Router) handleCreateSSOProvider", 1)[1].split(
|
|
"func (r *Router) handleUpdateSSOProvider", 1)[0]
|
|
self.assertIn("provider.ID = uuid.NewString()", create)
|
|
self.assertIn('Message: "OIDC discovery successful"', handlers)
|
|
|
|
def test_cookie_server_and_idp_lifetimes_are_distinct(self):
|
|
text = guide_section("### Sessions and `offline_access`", "#### Changing or removing access")
|
|
for expected in (
|
|
"different lifetimes", "does not guarantee a 30- or 90-day",
|
|
"actually issue a refresh token", "SSO can work without",
|
|
"24-hour lifetime", "sliding expiry", "not extend the browser cookie",
|
|
"request-driven, not continuous polling", "before refresh completes",
|
|
):
|
|
self.assertIn(expected, text)
|
|
|
|
def test_token_exchange_failure_is_not_confused_with_missing_provider(self):
|
|
text = guide_section("### Sessions and `offline_access`", "#### Changing or removing access")
|
|
for expected in (
|
|
"token exchange", "invalidates that Pulse session",
|
|
"Failure to initialise the provider", "no matching enabled provider",
|
|
"skips refresh", "changed issuer/client ID", "encrypted when persisted",
|
|
"never publish session files",
|
|
):
|
|
self.assertIn(expected, text)
|
|
self.assertNotIn("automatically invalidated", text)
|
|
|
|
def test_login_claims_and_provider_changes_do_not_promise_revocation(self):
|
|
text = guide_section("#### Changing or removing access", "## 📚 Provider Examples")
|
|
for expected in (
|
|
"every non-empty restriction must pass", "applied at login",
|
|
"does **not** re-evaluate", "existing Pulse session or role assignments",
|
|
"Disabling or deleting a provider", "must not be treated as revoking",
|
|
"independent administrator login", "Block future sign-ins",
|
|
"Pro RBAC", "RBAC.md#removing-user-access", "provider-scoped identity",
|
|
"active Pulse sessions", "later authorised SSO login can recreate",
|
|
"not proof that an old session lost access", "targeted user removal is unavailable",
|
|
"network or authenticated reverse-proxy access boundary",
|
|
"blocking only the IdP login is not enough", "Do not disable Pulse authentication",
|
|
):
|
|
self.assertIn(expected, text)
|
|
|
|
def test_diagnosis_does_not_request_debug_restart_or_secrets(self):
|
|
text = guide_section("### Safe diagnosis", "\0")
|
|
for expected in (
|
|
"existing login error", "claim names locally", "not paste a full IdP response",
|
|
"Do not enable Debug or restart", "locally reviewed, redacted error",
|
|
"client secrets", "authorization codes", "ID/access/refresh tokens",
|
|
"full callback query strings", "HAR exports", "Copy as cURL",
|
|
"Never disable TLS verification or access restrictions",
|
|
):
|
|
self.assertIn(expected, text)
|
|
self.assertNotIn("```", text)
|
|
self.assertNotIn("LOG_LEVEL=debug", GUIDE.read_text())
|
|
|
|
def test_session_and_refresh_claims_match_source(self):
|
|
router = (ROOT / "internal/api/router.go").read_text()
|
|
session = router.split("func (r *Router) establishOIDCSession(", 1)[1].split(
|
|
"// handleLogin", 1
|
|
)[0]
|
|
self.assertIn("24*time.Hour", session)
|
|
self.assertEqual(session.count("MaxAge: 86400") + session.count("MaxAge: 86400"), 2)
|
|
store = (ROOT / "internal/api/session_store.go").read_text()
|
|
sliding = store.split("func (s *SessionStore) ValidateAndExtendSession(", 1)[1]
|
|
self.assertIn("now.After(session.ExpiresAt)", sliding)
|
|
self.assertIn("session.ExpiresAt = now.Add(session.OriginalDuration)", sliding)
|
|
auth = (ROOT / "internal/api/auth.go").read_text()
|
|
self.assertIn("go refreshOIDCSessionTokens(cfg, cookie.Value, session)", auth)
|
|
refresh = auth.split("func refreshOIDCSessionTokens(", 1)[1]
|
|
self.assertIn("if oidcCfg == nil", refresh)
|
|
self.assertIn("if err != nil", refresh)
|
|
self.assertIn("GetSessionStore().InvalidateSession(sessionToken)", refresh)
|
|
self.assertIn("GetSessionStore().UpdateOIDCTokens", refresh)
|
|
self.assertNotIn("resolveGroupRoles", refresh)
|
|
self.assertNotIn("AllowedGroups", refresh)
|
|
|
|
def test_claim_gating_and_targeted_removal_match_source(self):
|
|
callback = (ROOT / "internal/api/oidc_handlers.go").read_text()
|
|
for marker in ("len(provider.AllowedEmails) > 0", "len(provider.AllowedDomains) > 0",
|
|
"len(provider.AllowedGroups) > 0", "applySSORoleAssignments"):
|
|
self.assertIn(marker, callback)
|
|
providers = (ROOT / "internal/api/identity_sso_handlers.go").read_text()
|
|
mutations = providers.split("func (r *Router) handleUpdateSSOProvider(", 1)[1].split(
|
|
"func (r *Router) saveSSOConfig(", 1
|
|
)[0]
|
|
self.assertNotIn("InvalidateUserSessions", mutations)
|
|
self.assertNotIn("InvalidateSession", mutations)
|
|
remove = (ROOT / "internal/api/access_control_handlers.go").read_text()
|
|
self.assertIn("InvalidateUserSessions(username)", remove)
|
|
self.assertIn('"Cannot remove your own user access"', remove)
|
|
rbac = (ROOT / "docs/RBAC.md").read_text()
|
|
self.assertIn("### Removing User Access", rbac)
|
|
self.assertIn("Requires:", rbac)
|
|
|
|
def test_served_guide_matches_source(self):
|
|
self.assertEqual(GUIDE.read_bytes(), (ROOT / "frontend-modern/public/docs/OIDC.md").read_bytes())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|