From 497ea3b669e52376e24d1dbba7ece412e60d8ec7 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 25 Jul 2026 17:55:30 -0400 Subject: [PATCH] Centralize client portal access checks through contactCan() Replaces the duplicated primary/billing/technical checks across portal pages, post.php handlers, nav, and dashboard with contactCan()/enforceContactCan(). Same behavior, but the rules now live in one place instead of being copy-pasted, which is what let them drift before. file.php keeps its 404 response; ticket-visibility and approval-routing checks are intentionally left as-is. --- client/assets.php | 5 +---- client/certificates.php | 5 +---- client/contact_add.php | 5 +---- client/contact_edit.php | 5 +---- client/contacts.php | 5 +---- client/document.php | 5 +---- client/documents.php | 5 +---- client/domains.php | 5 +---- client/file.php | 2 +- client/functions.php | 38 ++++++++++++++++++++++++++++++-- client/includes/header.php | 4 ++-- client/index.php | 4 ++-- client/invoices.php | 5 +---- client/post.php | 32 +++++++-------------------- client/quotes.php | 5 +---- client/recurring_invoices.php | 5 +---- client/saved_payment_methods.php | 5 +---- client/unpaid_invoices.php | 5 +---- 18 files changed, 62 insertions(+), 83 deletions(-) diff --git a/client/assets.php b/client/assets.php index 0ccdb1ba..6af6398e 100644 --- a/client/assets.php +++ b/client/assets.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('itdoc'); $assets_sql = mysqli_query($mysqli, "SELECT * FROM assets LEFT JOIN contacts ON asset_contact_id = contact_id WHERE asset_client_id = $session_client_id AND asset_archived_at IS NULL ORDER BY asset_type ASC, asset_name ASC"); ?> diff --git a/client/certificates.php b/client/certificates.php index b4afde77..5ef77bc0 100644 --- a/client/certificates.php +++ b/client/certificates.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('itdoc'); $certificates_sql = mysqli_query($mysqli, "SELECT certificate_id, certificate_name, certificate_domain, certificate_issued_by, certificate_expire FROM certificates WHERE certificate_client_id = $session_client_id AND certificate_archived_at IS NULL ORDER BY certificate_expire ASC"); ?> diff --git a/client/contact_add.php b/client/contact_add.php index 423c2a7c..75d4db07 100644 --- a/client/contact_add.php +++ b/client/contact_add.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('contacts'); ?> diff --git a/client/contact_edit.php b/client/contact_edit.php index d2c1ad1a..ecd18784 100644 --- a/client/contact_edit.php +++ b/client/contact_edit.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('contacts'); // Check for a contact ID if (!isset($_GET['id']) && !intval($_GET['id'])) { diff --git a/client/contacts.php b/client/contacts.php index 340e6db2..60a6944b 100644 --- a/client/contacts.php +++ b/client/contacts.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('contacts'); $contacts_sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_email, contact_primary, contact_technical, contact_billing FROM contacts WHERE contact_client_id = $session_client_id AND contacts.contact_archived_at IS NULL ORDER BY contact_created_at"); ?> diff --git a/client/document.php b/client/document.php index ae6384c0..adad8f45 100644 --- a/client/document.php +++ b/client/document.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'; img-src 'self' data:"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('itdoc'); //Initialize the HTML Purifier to prevent XSS require_once "../libs/htmlpurifier/HTMLPurifier.standalone.php"; diff --git a/client/documents.php b/client/documents.php index 07afdd38..c0eddec6 100644 --- a/client/documents.php +++ b/client/documents.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('itdoc'); $documents_sql = mysqli_query($mysqli, "SELECT document_id, document_name, document_created_at, folder_name FROM documents LEFT JOIN folders ON document_folder_id = folder_id WHERE document_client_visible = 1 AND document_client_id = $session_client_id AND document_archived_at IS NULL ORDER BY folder_id, document_name DESC"); ?> diff --git a/client/domains.php b/client/domains.php index 59f12829..0ac187bc 100644 --- a/client/domains.php +++ b/client/domains.php @@ -8,10 +8,7 @@ header("Content-Security-Policy: default-src 'self'"); require_once "includes/inc_all.php"; -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { - header("Location: post.php?logout"); - exit(); -} +enforceContactCan('itdoc'); $domains_sql = mysqli_query($mysqli, "SELECT domain_id, domain_name, domain_expire FROM domains WHERE domain_client_id = $session_client_id AND domain_archived_at IS NULL ORDER BY domain_expire ASC"); ?> diff --git a/client/file.php b/client/file.php index 091352e1..1603086a 100644 --- a/client/file.php +++ b/client/file.php @@ -17,7 +17,7 @@ require_once 'functions.php'; // Documents section is for primary / technical contacts only - // same gate as client/document.php -if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) { +if (!contactCan('itdoc')) { http_response_code(404); exit("File not found"); } diff --git a/client/functions.php b/client/functions.php index 53c084ee..142b8225 100644 --- a/client/functions.php +++ b/client/functions.php @@ -7,8 +7,7 @@ /* * Verifies a contact has access to a particular ticket ID, and that the ticket is in the correct state (open/closed) to perform an action */ -function verifyContactTicketAccess($requested_ticket_id, $expected_ticket_state) -{ +function verifyContactTicketAccess($requested_ticket_id, $expected_ticket_state) { // Access the global variables global $mysqli, $session_contact_id, $session_contact_primary, $session_contact_is_technical_contact, $session_client_id; @@ -38,6 +37,41 @@ function verifyContactTicketAccess($requested_ticket_id, $expected_ticket_state) } +/* + * Portal access control - single source of truth for what a logged-in contact can do. + * Primary contacts have full access; others are gated by their billing / technical flags. + * Capabilities are named by area so the rule for one can change without touching callers. + */ +function contactCan($capability) { + global $session_contact_primary, $session_contact_is_billing_contact, $session_contact_is_technical_contact; + + // Primary contacts can do everything in the portal + if ($session_contact_primary == 1) { + return true; + } + + switch ($capability) { + case 'accounting': // invoices, quotes, recurring invoices, saved payment methods + return (bool) $session_contact_is_billing_contact; + + case 'itdoc': // assets, certificates, domains, documents, files + case 'contacts': // view / manage contacts + return (bool) $session_contact_is_technical_contact; + + default: // unknown capability -> deny (fail closed) + return false; + } +} + +/* + * Enforce a capability at the top of a page or handler - bounce the contact out if they lack it. + */ +function enforceContactCan($capability) { + if (!contactCan($capability)) { + redirect("post.php?logout"); + } +} + /* * Returns appropriate FontAwesome icon for file extension */ diff --git a/client/includes/header.php b/client/includes/header.php index 9ef829e5..028a9974 100644 --- a/client/includes/header.php +++ b/client/includes/header.php @@ -49,7 +49,7 @@ header("X-Frame-Options: DENY"); // Legacy " href="/client/tickets.php">Tickets - +