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.
This commit is contained in:
johnnyq
2026-07-25 17:55:30 -04:00
parent 887063394a
commit 497ea3b669
18 changed files with 62 additions and 83 deletions

View File

@@ -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");
?>

View File

@@ -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");
?>

View File

@@ -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');
?>

View File

@@ -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'])) {

View File

@@ -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");
?>

View File

@@ -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";

View File

@@ -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");
?>

View File

@@ -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");
?>

View File

@@ -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");
}

View File

@@ -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
*/

View File

@@ -49,7 +49,7 @@ header("X-Frame-Options: DENY"); // Legacy
<a class="nav-link <?php if (basename($_SERVER['PHP_SELF']) == "tickets.php" || basename($_SERVER['PHP_SELF']) == "ticket_add.php" || basename($_SERVER['PHP_SELF']) == "ticket.php") {echo "active";} ?>" href="/client/tickets.php">Tickets</a>
</li>
<?php if (($session_contact_primary == 1 || $session_contact_is_billing_contact) && $config_module_enable_accounting == 1) { ?>
<?php if (contactCan('accounting') && $config_module_enable_accounting == 1) { ?>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle <?php echo in_array(basename($_SERVER['PHP_SELF']), ['invoices.php', 'quotes.php', 'autopay.php']) ? 'active' : ''; ?>" href="#" id="navbarDropdown1" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Finance
@@ -63,7 +63,7 @@ header("X-Frame-Options: DENY"); // Legacy
</li>
<?php } ?>
<?php if ($config_module_enable_itdoc && ($session_contact_primary == 1 || $session_contact_is_technical_contact)) { ?>
<?php if ($config_module_enable_itdoc && contactCan('itdoc')) { ?>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle <?php echo in_array(basename($_SERVER['PHP_SELF']), ['documents.php', 'contacts.php', 'domains.php', 'certificates.php']) ? 'active' : ''; ?>" href="#" id="navbarDropdown2" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Technical

View File

@@ -175,7 +175,7 @@ $sql_assigned_assets = mysqli_query(
</div>
<?php
// Billing Cards
if ($session_contact_primary == 1 || $session_contact_is_billing_contact) { ?>
if (contactCan('accounting')) { ?>
<div class="row">
@@ -211,7 +211,7 @@ if ($session_contact_primary == 1 || $session_contact_is_billing_contact) { ?>
<?php
// Technical Cards
if ($session_contact_primary == 1 || $session_contact_is_technical_contact) {
if (contactCan('itdoc')) {
?>
<div class="row">

View File

@@ -9,10 +9,7 @@ header("Content-Security-Policy: default-src 'self'");
require_once "includes/inc_all.php";
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
header("Location: post.php?logout");
exit();
}
enforceContactCan('accounting');
$invoices_sql = mysqli_query($mysqli, "SELECT * FROM invoices WHERE invoice_client_id = $session_client_id AND invoice_status != 'Draft' ORDER BY invoice_date DESC");
?>

View File

@@ -398,9 +398,7 @@ if (isset($_POST['add_contact'])) {
validateCSRFToken();
if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) {
redirect("post.php?logout");
}
enforceContactCan('contacts');
$contact_name = escapeSql($_POST['contact_name']);
$contact_email = escapeSql($_POST['contact_email']);
@@ -447,9 +445,7 @@ if (isset($_POST['edit_contact'])) {
validateCSRFToken();
if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) {
redirect("post.php?logout");
}
enforceContactCan('contacts');
$contact_id = intval($_POST['contact_id']);
// A contact cannot edit their own record - that would let them change their own roles
@@ -694,9 +690,7 @@ if (isset($_POST['create_stripe_customer'])) {
validateCSRFToken();
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
redirect("post.php?logout");
}
enforceContactCan('accounting');
// Get Stripe provider
$stripe_provider_result = mysqli_query($mysqli, "
@@ -784,9 +778,7 @@ if (isset($_GET['create_stripe_checkout'])) {
// This page is called by autopay_setup_stripe.js, returns a Checkout Session client_secret
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
redirect("post.php?logout");
}
enforceContactCan('accounting');
// Fetch Stripe provider info
$stripe_provider_result = mysqli_query($mysqli, "
@@ -854,9 +846,7 @@ if (isset($_GET['stripe_save_card'])) {
// validateCSRFToken(); Broken with Stripe Save Card JQ 2026-5-4
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
redirect("post.php?logout");
}
enforceContactCan('accounting');
// Get Stripe provider
$stripe_provider_result = mysqli_query($mysqli, "
@@ -985,9 +975,7 @@ if (isset($_GET['delete_saved_payment'])) {
validateCSRFToken();
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
redirect("post.php?logout");
}
enforceContactCan('accounting');
$saved_payment_id = intval($_GET['delete_saved_payment']);
@@ -1144,9 +1132,7 @@ if (isset($_POST['client_add_document'])) {
validateCSRFToken();
// Permission check - only primary or technical contacts can create documents
if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) {
redirect("post.php?logout");
}
enforceContactCan('itdoc');
$document_name = escapeSql($_POST['document_name']);
$document_description = escapeSql($_POST['document_description']);
@@ -1190,9 +1176,7 @@ if (isset($_POST['client_upload_document'])) {
validateCSRFToken();
// Permission check - only primary or technical contacts can upload documents
if ($session_contact_primary == 0 && !$session_contact_is_technical_contact) {
redirect("post.php?logout");
}
enforceContactCan('itdoc');
$document_name = escapeSql($_POST['document_name']);
$document_description = escapeSql($_POST['document_description']);

View File

@@ -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_billing_contact) {
header("Location: post.php?logout");
exit();
}
enforceContactCan('accounting');
$quotes_sql = mysqli_query($mysqli, "SELECT * FROM quotes WHERE quote_client_id = $session_client_id AND quote_status != 'Draft' ORDER BY quote_date DESC");
?>

View File

@@ -9,10 +9,7 @@
require_once "includes/inc_all.php";
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
header("Location: post.php?logout");
exit();
}
enforceContactCan('accounting');
$recurring_invoices_sql = mysqli_query($mysqli, "SELECT * FROM recurring_invoices
LEFT JOIN recurring_payments ON recurring_payment_recurring_invoice_id = recurring_invoice_id

View File

@@ -5,10 +5,7 @@
require_once "includes/inc_all.php";
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
header("Location: post.php?logout");
exit();
}
enforceContactCan('accounting');
// Initialize Stripe
require_once '../includes/stripe_init.php';

View File

@@ -11,10 +11,7 @@ header("Content-Security-Policy: default-src 'self'");
require_once "includes/inc_all.php";
if ($session_contact_primary == 0 && !$session_contact_is_billing_contact) {
header("Location: post.php?logout");
exit();
}
enforceContactCan('accounting');
$invoices_sql = mysqli_query($mysqli, "SELECT * FROM invoices WHERE invoice_client_id = $session_client_id AND (invoice_status = 'Viewed' OR invoice_status = 'Sent' OR invoice_status = 'Partial') ORDER BY invoice_date DESC");