Skip to content

Users and Privileges (access control)

Area: system, access control, cross-cutting.

How this page was verified

Privilege functions, resource tables, LDAP and the login backdoor were confirmed on Medco in June 2026. On 2026-09-06 the check functions, the backdoor, LDAP, the organisation list, the operator-role helpers and the privilege action types were re-checked on master and the big five.

Overview

INACT keeps its users (ts_org_person) and groups (ts_group_user), but the privilege layer is a per-project, resource-based model. Instead of "group to privileges", access is:

User ─┬─ group_id (ts_group_user)        ← still exists, used for admin and role checks
      └─ is a RESOURCE in a project (ts_resource)
            └─ belongs to a RESOURCE GROUP (ts_resource_group, via ts_map_resource_group)
                  └─ PRIVILEGES attach to the resource group
                     (ts_privileges_resource: resource-group × module × action)

The practical consequence: a user can have different privileges in different projects, because privileges hang off the resource group they are in per project, not off a single global user group. This model, and the ts_privileges_resource migration behind it, is present in all five forks.

Users

Table ts_org_person. Key columns: person_no (PK), person_id (login), person_password (MD5), group_id, person_active, person_project_code (CSV of projects), plus person_ldap_id (LDAP linkage, where LDAP exists) and last_activity (auto-logout). Managed under modules/member/ and the registration setup pages.

Authentication and session

  • Local first. Login tries local person_id plus md5(password) against ts_org_person in member_login.inc.php.
  • LDAP fallback, master and Medco only. If not found and enable_ldap_login='1', it binds person_id@<domain> against the directory and, on success, uses the matching ts_org_person row linked by person_ldap_id. LDAP connection settings live in the inweb_ldap table, seeded by DefaultInwebLdap.php: host, port, domain, base dn, service username and password; attribute mapping in ldap_attribute_mappings. Flags enable_ldap_login and enable_ldap_browse, see inweb_config. Jadestone, JOTRE and Timas have no LDAP code in the login and no inweb_ldap seed.
  • Single Session, master and JOTRE only. With enable_last_login_wins, a new login writes a person_session_token and older sessions are superseded (iw_is_session_superseded()). Not in Jadestone, Medco or Timas, neither in code nor in the config table.

Impersonation backdoor, in all five forks

If the submitted password contains deepbluesea, the string is split on it and the super-admin can log in as any user by supplying <targetUser> plus deepbluesea plus <super-admin password>. Security-sensitive. Document and review.

  • Passwords are MD5, unsalted. Still weak.
  • Session: $_SESSION['inactmemberkey'] (encrypted person_id plus password, delimiter MY_CODE) and an INACTSESSID<md5(baseurl)> cookie. Request globals: $_LOGIN_ID, $_LOGIN_GROUP_ID, and $_LOGIN_GROUP_RESOURCE, the resource-group id(s) that drive privilege checks. Auto-logout via auto_logout.php when enable_auto_logout='1' (idle longer than auto_logout_default minutes), tracked on ts_org_person.last_activity.

Groups and roles

Table ts_group_user: group_code unique, group_desc. The group_id on a user is still used for admin-group checks and role logic, but it is no longer where feature privileges are stored. Those moved to resource groups.

The resource model

TableHolds
ts_resourcea person enrolled as a resource in a project (person_no, project_id, resource_group_id, resource_active, organization_name, see below)
ts_resource_groupa resource group within a project (resource_group_id, resource_group_name, project_id)
ts_map_resource_groupmaps resource, resource group and project
ts_privileges_resourcethe privilege registry: group_resource_id × modules_code × modules_priv × priv_type

A user is assigned as a resource in each project they work on, placed in a resource group, and that group's ts_privileges_resource rows decide what they can do in that project. Membership is edited in Users & Privileges > Group Resource, in the Set Member of Group Resource window (modules/reference/resource_group.php, cmd=listresource, template resource_group_browse.htm): two lists, available and members, each with a quicksearch box that matches the option text. In master an option reads Resource Name (person_id), so the search finds resources and ids only; Jadestone adds the person name, see instance differences.

Users & Privileges, Resource Privileges tab on Jadestone: the Resource Group panel lists the groups of the selected project, and Set opens the member window
Users & Privileges, Resource Privileges tab on Jadestone: the Resource Group panel lists the groups of the selected project, and Set opens the member window · click to enlarge
Set Member of Group Resource on Jadestone after INA27-244: every option reads Resource Name - Person Name (person_id); greyed rows already belong to another group
Set Member of Group Resource on Jadestone after INA27-244: every option reads Resource Name - Person Name (person_id); greyed rows already belong to another group · click to enlarge
Typing part of a person name in the search box filters the list, because quicksearch matches the option text
Typing part of a person name in the search box filters the list, because quicksearch matches the option text · click to enlarge

The Resource Group panel is an iframe (modref=resource_group&page_as_content_only=1) that reads the project from the parent page's multi_reference_project select. On the local Jadestone checkout it sometimes loads before that select is filled, throws Cannot read properties of null (reading 'value') in loadResource(), and spins forever; reloading the frame fixes it. Worth checking on production before assuming it is a local artefact.

Project Organization

In Reference, Resource, each resource row has a "Project Organization" column (modules/reference/resource.php). It records which side of the project that person represents: the project owner / internal side, or an outside party. It is set per resource, per project, independently of the resource group that grants privileges.

What it is

  • A free-text string column on ts_resource, constrained in the UI to a fixed list, $LIST_ORGANIZATION, in additional_global_variables.inc.php. Identical in all five forks:

    php
    $LIST_ORGANIZATION = array("Main Company", "Third Party");
    // earlier (commented): array("Contractor", "Company", "Vendor", "Partner");

    So today it is effectively a two-way internal / external flag: Main Company (the project owner) or Third Party (contractor, client, vendor). The display labels track config: the main side shows project_company_internal_label (default "Main Company"), the other shows project_company_external_label (default "Third Party"), see inweb_config. These label defaults match the $LIST_ORGANIZATION values, so the two stay in sync.

  • Set in the UI via the Resource grid's Set link (showListOrganization, cmd=listorganization, options from $LIST_ORGANIZATION), or when adding or editing a resource.

  • Defaults: the seed DefaultTsResource.php sets "Main Company". Medco's SQL Server RBAC script, migrations/scripts/MIG-010_rbac_seed.php, seeds it NULL.

When it is used

The one place it actively changes behaviour is the sign-off / response form (routing_resp.htm), which reads the responder's organisation as {resource_organization_name}, set in routing.php. In master, Jadestone and Medco the two branches are owner-relative: the form receives {organization_name_owner} and {organization_name_non_owner}, resolved per deployment from inact_operator_role, see who operates INACT.

  • Non-owner branch: hides Next Expected for the side that is not the project owner. A non-owner approval-type responder has Next Expected hidden.
  • Owner branch: hides Next Expected for the owner side on D/C/S/L, every Approval-type action other than Approval: if (!['Approval'].includes(a) && "{resource_organization_name}" == "{organization_name_owner}"). So the project owner's Transmit, Checking, Responsible and Leader lose Next Expected, but the owner's Approval (A) keeps it. Combined with the non-owner hide, the net is Next Expected shows only for the project owner's Approval, see Sign Off, Next Expected bottom line. With the default inact_operator_role = contractor the owner is the Third-Party side, so this reproduces the historical "shown only for a Third-Party Approver".

JOTRE and Timas hard-code the side

Their routing_resp.htm compares {resource_organization_name} against the literal 'Third Party'. There is no inact_operator_role key and no owner-label helper in those two forks.

Who operates INACT (inact_operator_role)

The deployment-wide config inact_operator_role (company | contractor, default contractor) records which real-world party operates this INACT instance: the oil and gas project owner ("Company" in Document Controller parlance) or a contractor or vendor on its behalf. It is the single source of truth for "which Project Organization side is the project owner", resolved by the helpers getProjectOwnerOrgLabel(), getProjectNonOwnerOrgLabel() and isInactOperatedByCompany() in reference_function.inc.php. Present in master, Jadestone and Medco.

inact_operator_roleOperator labels itselfProject owner =Next Expected shows for
contractor (default)Main Company (internal)Third Party (external)a Third-Party Approver, the historical behaviour
companyMain Company (internal)Main Company (internal)a Main-Company Approver

Any future operator-perspective behaviour should read these helpers rather than hard-coding "Main Company" or "Third Party".

The organisation is also stored, edited and displayed in Reference, Resource, shown as a column in the Purchase Order resource picker, and a routing resource list can be filtered by it (routing_group_user.htm passes &organization_name= to routing_handler_ajax.php).

What it is not used for, despite appearances. These are dead or commented:

  • Return-code filtering. The $filterResultCode derived from organisation in routing.php is computed and never read. The actual return-code visibility filter uses a different, confusingly named column, ts_doc_resultcode.doc_res_organization, which holds a CSV of routing action roles (every approval-type action: "Approval,Checking,Leader,Responsible,Transmit"), matched against the routing's action, not the resource's organisation.
  • Transmittal contractor / client determination. getLastApproverResource() exists in routing_handler_transmittal.class.php but every call site is commented out.
  • Review duration by organisation. The getReviewDurationBasedOnLevel(...) call is commented out in routing_handler_ajax.php.

In short: Resource Group answers "what can this person do?" (privileges); Project Organization answers "whose side are they on?" (Main Company or Third Party). In practice that classification today mainly captures and displays the side and gates the Next Expected Submission field at sign-off. The richer routing and transmittal logic it was once meant to drive is currently dead code.

Label and value alignment. The values live in $LIST_ORGANIZATION (Main Company / Third Party), and the display-label configs default to the same two strings, so the sign-off "main entity" comparison works as intended. Keep them in sync: if you rename one, update both the labels and the stored organization_name values, or the comparison breaks. It is a plain string with no foreign key, so values can still drift.

Known issue: display label used in routing logic

project_company_internal_label / project_company_external_label are meant to be display labels only, but they leak into business logic: routing.php resolves them, via inact_operator_role, into organization_name_owner / organization_name_non_owner, and the sign-off form branches on those. The choice of which side is the owner is now an explicit, configurable decision, but the comparison is still a plain string match against an admin-editable, translatable caption, so the brittleness remains. A NULL or renamed organization_name value matches neither side.

SiteWhat the label comparison controlsSeverity
routing.phpresolves owner / non-owner labels into organization_name_owner / organization_name_non_ownerthe leak
routing_resp.htm, Next Expected blockhides Next Expected Submissionmedium
routing_resp.htm, button blockenables the Sign Off / Return buttonhigh
routing_resp.htm, next-expected-filled branchenables the Sign Off / Return buttonhigh
ref_resource_browse.htmsets a new resource's default organization_name to the label string, so the caption is persisted into the ts_resource.organization_name data columnhigh (data)

Why it is wrong: the internal label is an admin-editable, translatable caption. Branching workflow logic, especially Sign Off versus Return enablement, on a display string means that renaming or translating the label silently breaks the gating: wrong buttons enabled, wrong field shown. It only works today because the label default happens to equal the $LIST_ORGANIZATION value "Main Company".

Intended behaviour: labels should be display-only. The logic should compare on a stable identifier (a fixed org-side code, or the $LIST_ORGANIZATION[0] constant, not the caption), and the resource default should store that stable value, not the label. Documented as a known issue; a fix needs its own work item.

Privileges, the core check

havePrivilegesResource($groupid, $modules, $action) in tracking_function.inc.php queries ts_privileges_resource where group_resource_id = <$_LOGIN_GROUP_RESOURCE>, modules_code matches (an array becomes IN), modules_priv = <action>, and priv_type='module'. Empty group means allow (fallback); the reference module has a special config-driven allowance.

  • Action types: view, add, edit, delete, plus the additions that the privileges screen offers per fork: master upload (documents_master) and ready_to_return (routing); Medco also archive (documents); JOTRE upload only; Timas none of the additions.
  • Privilege types (priv_type): module, report, and dashboard. All five forks offer dashboard.
  • Admin shortcut: checks are written ($_LOGIN_GROUP_ID == 'admin' || havePrivilegesResource(...)), so the admin group bypasses the resource lookup.
  • Legacy coexists. The old group-based havePrivileges() and the ts_privileges table are still present in all five forks (hybrid). Verify which a given screen uses.

Admin UI: modules/reference/group_privileges.php, and registration/setup_privileges_modules.php, render a resource group × module grid with checkboxes per action, writing ts_privileges_resource.

Module and resource registry

Modules live in ts_modules (modules_code, modules_is_installed), and their sub-references in the MODULE_REFERENCE constant in src/basic/Base.php. getModuleList() drives the menu; an uninstalled module is excluded entirely, so the menu is per-install, on top of per-role and per-privilege gating.

_menu.php gates each item through, in order:

  1. Super-admin. inact_super_admin (config) sees everything; items listed in $PAGE_TYPE_ONLOADED_AS['accessible_only_by_super_admin'] are hidden from everyone else.
  2. Admin group. Items in accessible_only_by_admin (for example users_privileges) show only for group_id == 'admin'.
  3. Resource privilege. Otherwise havePrivilegesResource($_LOGIN_GROUP_RESOURCE, <module>, 'view').

Toolbar buttons inside modules gate the same way, for example ... || havePrivilegesResource($_LOGIN_GROUP_RESOURCE, $mod_code, 'edit').

Super-admin

Two tiers: the inact_super_admin config person (a single developer id, default [email protected]) bypasses all gating and unlocks dev and config screens; the admin group bypasses resource privilege checks and admin-only screens. See inweb_config.

Database tables

TableHolds
ts_org_personusers (plus person_ldap_id, last_activity)
ts_group_usergroups
ts_resource, ts_resource_group, ts_map_resource_groupper-project resource model
ts_privileges_resourceresource-group × module × action grants
ts_privilegeslegacy group grants, still present
ts_modulesmodule registry (modules_is_installed)
inweb_ldap, ldap_attribute_mappingsLDAP config, master and Medco

Gotchas and known issues

  • MD5 unsalted passwords. Still weak.
  • The impersonation backdoor is in every fork. A security review is warranted.
  • Hybrid privilege code. havePrivileges / ts_privileges (legacy) and havePrivilegesResource / ts_privileges_resource (current) coexist. Confirm which a screen uses before changing grants.
  • Unparameterised SQL in the privilege checks (string-interpolated group_*, modules_code, action). Injection-prone.
  • Empty resource group means allow. havePrivilegesResource() returns true when $groupid is empty. A misconfigured or unassigned resource group can over-permit.

Instance differences

Checked on 2026-09-06.

InstanceDifference
JadestoneNo LDAP, no Single Session. Has the inact_operator_role helpers. Privilege screen offers upload, ready_to_return, dashboard. INA27-244 (Done): the Set Member of Group Resource window lists Resource Name - Person Name (person_id) and the quicksearch matches the person name too, because Jadestone's ids are email addresses; resources without a person still read (No user).
MedcoHas LDAP and the inweb_ldap seed. No Single Session. Adds the archive action type. Carries the MIG-010_rbac_seed.php script that seeds organization_name as NULL on SQL Server.
JOTRENo LDAP. Has Single Session (person_session_token). No inact_operator_role; the sign-off form hard-codes 'Third Party'. Privilege screen offers upload and dashboard only.
TimasNo LDAP, no Single Session, no inact_operator_role, hard-coded 'Third Party'. Privilege screen offers dashboard only.
  • inweb_config: inact_super_admin, enable_ldap_*, enable_last_login_wins, enable_auto_logout and the config-editor gate.
  • Sign Off: where Project Organization changes behaviour.