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_idplusmd5(password)againstts_org_personinmember_login.inc.php. - LDAP fallback, master and Medco only. If not found and
enable_ldap_login='1', it bindsperson_id@<domain>against the directory and, on success, uses the matchingts_org_personrow linked byperson_ldap_id. LDAP connection settings live in theinweb_ldaptable, seeded byDefaultInwebLdap.php:host,port,domain, basedn, serviceusernameandpassword; attribute mapping inldap_attribute_mappings. Flagsenable_ldap_loginandenable_ldap_browse, see inweb_config. Jadestone, JOTRE and Timas have no LDAP code in the login and noinweb_ldapseed. - Single Session, master and JOTRE only. With
enable_last_login_wins, a new login writes aperson_session_tokenand 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, delimiterMY_CODE) and anINACTSESSID<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 viaauto_logout.phpwhenenable_auto_logout='1'(idle longer thanauto_logout_defaultminutes), tracked onts_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
| Table | Holds |
|---|---|
ts_resource | a person enrolled as a resource in a project (person_no, project_id, resource_group_id, resource_active, organization_name, see below) |
ts_resource_group | a resource group within a project (resource_group_id, resource_group_name, project_id) |
ts_map_resource_group | maps resource, resource group and project |
ts_privileges_resource | the 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.



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, inadditional_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 showsproject_company_external_label(default "Third Party"), see inweb_config. These label defaults match the$LIST_ORGANIZATIONvalues, 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.phpsets"Main Company". Medco's SQL Server RBAC script,migrations/scripts/MIG-010_rbac_seed.php, seeds itNULL.
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'sApproval(A) keeps it. Combined with the non-owner hide, the net is Next Expected shows only for the project owner'sApproval, see Sign Off, Next Expected bottom line. With the defaultinact_operator_role = contractorthe 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_role | Operator labels itself | Project owner = | Next Expected shows for |
|---|---|---|---|
contractor (default) | Main Company (internal) | Third Party (external) | a Third-Party Approver, the historical behaviour |
company | Main 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
$filterResultCodederived from organisation inrouting.phpis 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 inrouting_handler_transmittal.class.phpbut every call site is commented out. - Review duration by organisation. The
getReviewDurationBasedOnLevel(...)call is commented out inrouting_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.
| Site | What the label comparison controls | Severity |
|---|---|---|
routing.php | resolves owner / non-owner labels into organization_name_owner / organization_name_non_owner | the leak |
routing_resp.htm, Next Expected block | hides Next Expected Submission | medium |
routing_resp.htm, button block | enables the Sign Off / Return button | high |
routing_resp.htm, next-expected-filled branch | enables the Sign Off / Return button | high |
ref_resource_browse.htm | sets a new resource's default organization_name to the label string, so the caption is persisted into the ts_resource.organization_name data column | high (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: masterupload(documents_master) andready_to_return(routing); Medco alsoarchive(documents); JOTREuploadonly; Timas none of the additions. - Privilege types (
priv_type):module,report, anddashboard. All five forks offerdashboard. - Admin shortcut: checks are written
($_LOGIN_GROUP_ID == 'admin' || havePrivilegesResource(...)), so theadmingroup bypasses the resource lookup. - Legacy coexists. The old group-based
havePrivileges()and thets_privilegestable 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 and UI gating, three layers
_menu.php gates each item through, in order:
- 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. - Admin group. Items in
accessible_only_by_admin(for exampleusers_privileges) show only forgroup_id == 'admin'. - 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
| Table | Holds |
|---|---|
ts_org_person | users (plus person_ldap_id, last_activity) |
ts_group_user | groups |
ts_resource, ts_resource_group, ts_map_resource_group | per-project resource model |
ts_privileges_resource | resource-group × module × action grants |
ts_privileges | legacy group grants, still present |
ts_modules | module registry (modules_is_installed) |
inweb_ldap, ldap_attribute_mappings | LDAP 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) andhavePrivilegesResource/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()returnstruewhen$groupidis empty. A misconfigured or unassigned resource group can over-permit.
Instance differences
Checked on 2026-09-06.
| Instance | Difference |
|---|---|
| Jadestone | No 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). |
| Medco | Has 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. |
| JOTRE | No 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. |
| Timas | No LDAP, no Single Session, no inact_operator_role, hard-coded 'Third Party'. Privilege screen offers dashboard only. |
Related
- inweb_config:
inact_super_admin,enable_ldap_*,enable_last_login_wins,enable_auto_logoutand the config-editor gate. - Sign Off: where Project Organization changes behaviour.