Skip to content

Carbon Calculator access gates

Before a Moodle user can see anything in the Carbon Calculator, they pass a chain of gates: their School account must be linked to a company, that company must have a valid UK Companies House number (CRN), the company may have to be flagged as a CC Partner or S3 Partner, and the user must hold a Carbon-eligible Moodle role. Every denial is written to an append-only log.

The Carbon Calculator was rolled out in phases to selected partner organisations, and it keys all data off the company’s CRN — a report belongs to a company, not a person, and suppliers are matched to companies by CRN. A user with no company, or a company with a placeholder CRN like 00000000, would create orphaned or colliding data. The gates enforce that up front rather than failing deeper in.

PathWhat it does
wp-content/plugins/carboncalc/includes/LMS.php:119carbon_company_access_gate_detail() — the company/CRN/partner gate
wp-content/plugins/carboncalc/includes/LMS.php:448carbon_login_blocked_no_permitted_carbon_role() — role gate
wp-content/themes/supplychainschool/inc/classes/LMSClient.php:1263Gate invocation during login; redirects on block
wp-content/themes/carboncalculator/functions.php:797carboncalculator_enforce_company_crn_for_lms_session() on template_redirect (priority 8)
wp-content/plugins/carboncalc/includes/Rest.php:207must_have_company() — permission callback for nearly every REST route
wp-content/plugins/carboncalc/includes/LoginAudit.php:24log_access_denied() → wp-content/carboncalc-login-denied.log
wp-content/plugins/carboncalc/includes/AdminLoginLogPage.phpwp-admin → Carbon Calculator → Carbon access log

The gate runs at three points: during login (LMSClient::log_in), on every Carbon page load (template_redirect), and on every REST request (must_have_company). All three call into CarbonCalc\LMS::carbon_company_access_gate_detail().

flowchart TD
  A[lms_user_id] --> B{cc_users row exists}
  B -->|no| R1[no_cc_users_row]
  B -->|yes| C{moodle_company_id set}
  C -->|no| R2[no_moodle_company_on_user]
  C -->|yes| D{cc_company row exists}
  D -->|no| D2[sync_roles_for_email then stub row]
  D2 -->|still none| R3[no_cc_company_row]
  D -->|yes| E{CRN valid}
  E -->|no + setting on| R4[missing_crn or invalid_crn]
  E -->|ok| F{partners_only setting}
  F -->|on and not partner| R5[not_cc_partner]
  F -->|off or partner| G{s3_partners_only setting}
  G -->|on and not S3| R6[not_s3_partner]
  G -->|pass| H[allowed - then role gate]

The gate is self-healing: when there is no cc_company row it calls sync_roles_for_email() to re-pull the Moodle payload, and failing that inserts a minimal stub row (LMS.php:1097). Pass $allow_writes = false to get pure diagnostics with no side effects — that’s what the admin lookup screens do.

Gate reasons map to user-facing messages via carbon_company_gate_login_error_param() (LMS.php:414), which produces a ?login_error= / ?cc_access_error= code rendered by template-log-in.php:31 and carboncalculator_get_index_access_error_message() (carboncalculator/functions.php:749).

CRN validation lives in UkCompanyCrn::normalize() / ::is_valid() (includes/UkCompanyCrn.php). Normalisation strips non-alphanumerics and uppercases; validation rejects placeholders such as all-zeros.

Role gate. Roles are Moodle shortnames synced into cc_user_roles on login. The matrix lives in RolePermissions (includes/RolePermissions.php):

Matrix columnMoodle shortnames
Default user (view only)tlactionplans_company_member, scs_cc_viewer
Carbon Reporterscs_cc_reporter
Partner Carbon Adminscs_cc_admin
School Admintlactionplans_company_admin, tlactionplans_company_owner, tlactionplans_school_admin
Partner admin (supply-chain view bypass)tlactionplans_partner_admin

Access allowlist = Reporter + Partner Carbon Admin + School Admin + Partner admin + tlactionplans_partner_owner + tlactionplans_partner_member (RolePermissions.php:217). Viewer allowlist = the Default user roles.

Two counter-intuitive rules, both deliberate:

  • A user with zero synced roles is allowed in as viewer-only, not blocked (user_has_calculator_role_access() returns true for an empty array, RolePermissions.php:520; the comment at LMSClient.php:1285 confirms this was a deliberate change).
  • scs_cc_admin (Partner Carbon Admin) is view-only on My Company Emissions even if scs_cc_reporter is also assigned — user_can_edit_company_reports() returns false for that role before it checks reporter (RolePermissions.php:285).

Partner flags. cc_company.is_partner is the CC Partner flag, synced from Moodle scs_is_cc_partner (with legacy fallback scs_is_partner). The S3 Partner flag is cc_s3_partner inside cc_company.profile_json, with a live Moodle-CRM fallback by CRN cached for an hour in the transient cc_s3_partner_crm_{moodle_company_id} (LMS.php:325).

Settings live on the Carbon subsite’s options table (read through FeatureSettings, which switches blogs — includes/FeatureSettings.php:166):

OptionEffect
carboncalc_feature_partners_only_accessRequire cc_company.is_partner
carboncalc_feature_s3_partners_only_accessRequire cc_s3_partner
carboncalc_feature_sharing_enabledEnables report sharing
carboncalc_feature_debug_modeVerbose debug UI
carboncalc_feature_sic_lookup_indicatorShows SIC lookup provenance
carboncalc_block_users_without_crn (AdminSettingsPage::OPT_BLOCK_USERS_WITHOUT_CRN)The CRN gate; defaults to ON

Constants: CARBONCALC_DEV_BYPASS short-circuits the whole gate and returns company id 1 (LMS.php:68). CARBONCALC_SKIP_AUTH disables front-end auth checks in the theme. Both are local-only.

  • The CRN gate fails open only if the option row exists and is falsy. is_block_users_without_crn_enabled() (FeatureSettings.php:65) returns true unless a row exists on the Carbon blog or blog 1 that reads '0'/false. A missing option means blocking is ON.
  • The setting is read from two blogs. Legacy saves landed on blog 1. clear_legacy_crn_block_option_copies() (FeatureSettings.php:206) exists to clean those up. If the gate behaves inconsistently between wp-admin and the front end, check for a stale copy on blog 1.
  • must_have_company() returns true when the LMS user id is 0 but a company id resolved (Rest.php:214-217). A session with a company but no resolvable LMS id skips the gate for REST.
  • Two REST routes are deliberately public (permission_callback => '__return_true'): /steps and /fields (Rest.php:106, :130). Those return configuration, not company data.
  • The theme’s REST routes have no permission callbacks at all — every route in carboncalculator_rest_api_init() (carboncalculator/functions.php:381) uses __return_true and checks auth inside the callback. If you add a route there, you must call carboncalculator_require_supply_chain_edit_permission() or equivalent yourself; nothing does it for you.
  • The gate writes to the database (stub company rows, role sync) as a side effect of a read-looking call. Pass $allow_writes = false from anything diagnostic.
  • carbon_login_blocked_no_permitted_carbon_role() returns false when the user has no roles at all — check carbon_login_blocked_no_synced_roles() separately if you need that distinction.
  • New gate conditions go in carbon_company_access_gate_detail(), with a new reason string, a case in carbon_company_gate_login_error_param(), a message in template-log-in.php, and a row in carbon_company_gate_steps() (LMS.php:652) so the admin diagnostics screen still explains the outcome.
  • New roles: add the shortname to the right constant in RolePermissions, not to a call site. Every allowlist is filterable (carboncalc_calculator_access_role_allowlist, carboncalc_report_editor_roles, carboncalc_supply_chain_editor_roles, carboncalc_supply_chain_view_roles) — prefer the filter for environment-specific overrides.
  • Also update get_matrix_rows() (RolePermissions.php:134) — that table is the client-facing spec rendered in wp-admin, and it is maintained by hand alongside the runtime checks. The two can drift.
  • Verify with wp-admin → Carbon Calculator → User Lookup: it renders the live gate steps, the Moodle payload hints, and the effective permission table for any user, without writes.
  • Denials land in wp-content/carboncalc-login-denied.log as one JSON object per line. Read it with the Carbon access log admin screen or LoginAudit::read_tail().

None. RolePermissions is pure enough to unit test (arrays in, booleans out) and would be the highest-value place to start.

Related: [[moodle-sso-and-sessions]], [[company-lookup-integrations]], [[carbon-admin-and-debug-tools]].