# IDOR and tenant-isolation audit

Date: 23 October 2026
Application: Zentrik HRMS
Source baseline: `1c22897`
Environment: local application, isolated SQLite/MySQL fixtures and browser database

## Result

Two authorization defects were reproduced and fixed. The audit did not demonstrate a cross-company document download. The tenant identifier in a URL is a routing identifier, not an authorization credential; security depends on the authenticated account, tenant context, permissions, organizational scope and parent-child checks.

This is an application source review and regression assessment, not a guarantee that every possible authorization flaw is absent. Production was not probed or changed. Hosting permissions, actual S3 bucket policies, reverse-proxy rules, deployed configuration and existing customer-specific roles require separate deployment verification.

## Findings and fixes

### IDOR-01: API creation commands did not enforce the owner's current permission

Severity: High. Status: fixed locally.

An API token with `organization:write` or `employees:write` could call the organization/employee creation services after its owner lost the corresponding management permission. Those services rely on their web callers to authorize, but the API caller previously checked token abilities and subscription entitlement alone. This was an authorization bypass within the token's tenant, not a demonstrated escape into another tenant.

The regression reproduced an organization creation response of HTTP 200 after the token owner was changed to a read-only role, where HTTP 403 was required.

`ApiService::command` now requires `organization.manage` for organization units/positions and `employees.manage` for employee creation before execution or receipt replay. Existing self-service command rules remain enforced by their domain services. Organization command payloads now use the web interface's validated field allowlist, with tenant-scoped unique codes. Tenant/record IDs supplied outside the contract cannot change record ownership. Related organization objects continue to be resolved inside the current tenant and invalid writes roll back.

Tests cover current-role downgrade, both organization creation commands, employee creation, replay after downgrade, read-only token abilities, foreign parent/department IDs, malformed input and forged ownership fields.

### IDOR-02: Generic workflow metadata exposed private employee-service run references

Severity: Medium. Status: fixed locally.

The ordinary workflow screen excluded private employee-service runs, but the generic workflow API and module-history list did not. They could disclose the service workflow's subject/reference, ID and status without going through the employee-service access rules. This did not expose encrypted bank payloads, salary facts or file bytes in the tested paths.

The regression reproduced a private service marker in the API's workflow list. A shared `withoutPrivateServices` query scope now excludes these records consistently in the API, module history and ordinary workflow screen. The dedicated employee-service interfaces retain their existing request, scope, revision and file authorization. The regression also verifies that ordinary workflows remain listed.

### Reviewed behavior: unsigned letter previews

A requester can preview their own generated letter before issue. The PDF is explicitly marked `PREVIEW — NOT ISSUED`, with signature/stamp images omitted. This is intended draft behavior. Final signed files still require completion, the current request revision and the configured file-access policy. Another employee cannot access the request, its preview or its files.

## Coverage and access checks

The new `IdorSecurityTest` discovers registered routes rather than maintaining a static list. At this revision it exercises 387 tenant web/session-API route-and-method combinations against a real second company and requires HTTP 404. It verifies that the generated URL matches the intended route before sending the request. It separately tests all 66 platform routes against a company administrator, all 13 bearer resource types and all 21 bearer commands against a foreign-tenant token.

The route matrix proves the outer tenant boundary for those routes; it is not a full valid-payload fuzz test of every object parameter or role combination. Targeted object tests and existing module regressions cover the deeper checks below.

| Area | Checks reviewed / exercised |
| --- | --- |
| Identity and platform | Real cross-company accounts, company/platform separation, foreign role injection, missing tenant context, tenant ownership on model creation, role downgrades, token expiry/revocation/disabled owners, custom-domain tenant matching |
| Organization and employees | Foreign hierarchy references, department-scoped HR read/edit, scoped lists, employee exports, effective reporting/position checks, personal-record permissions, linked login ownership |
| Documents and storage | Own download/preview success, another employee's file denial, foreign document IDs, sibling version substitution, uploader-owned staging/OCR/removal, private filesystem serving disabled, scoped HR download denial, encrypted storage profiles |
| Employee services and certificates | Another employee's request/preview/file denial, pending signed PDF blocked, output bound to request, generic document route cannot expose service-private files, post-approval owner download, scan-policy rejection, private workflow metadata |
| Attendance and leave | Scoped employee queries, own versus management access, correction/approval rules, export scoping, policy/evidence authorization through existing module regressions |
| Payroll | Self payslip ownership and released status, company-wide payroll permission restriction, run/item visibility, export authorization, independent bank/payroll approvals |
| Recruitment and lifecycle | Tenant and organizational scope on vacancies, candidates, lifecycle records and decisions; intentionally public published careers pages remain public |
| Assets and fleet | Tenant-owned equipment/custody IDs, scoped employees, mutation permissions and linked-document authorization |
| Tasks, announcements and forms | Assignee/audience boundaries, task child records, foreign IDs, notification ownership, form publication and existing-request access |
| Livewire and broadcasts | Locked tenant slug, membership recheck on component actions, recipient-only notification queries, tenant/user-specific private channel authorization |
| Reports, history and integrations | Domain permission plus export permission, captured tenant context during streamed exports, private service history filtering, scoped tokens and API command receipts |

Public logos/favicons, published career vacancies/application forms, sign-in and infrastructure health/domain-verification endpoints are intentional public surfaces. They do not provide private document download paths. Local/private disks have `serve=false`; private document bytes are delivered through authorized controllers.

The IDOR route matrix disables throttling only in its test fixture so rate limits cannot mask authorization outcomes. Authentication, tenant resolution, policies and organizational scoping remain enabled. File tests use an isolated encryption key/storage directory and fake scanning; they do not assess the production scanner or S3 credentials.

## Verification

- Full SQLite regression suite: **539 passed, 4 skipped, 5,006 assertions**. No failures or errors.
- Employee-services desktop/mobile browser workflows: 2 passed.
- Whole-repository UTF-8 and Pint checks: passed.
- Focused MySQL security/module regression run: **165 passed, 2,222 assertions**.
- New route/object matrix on MySQL: **7 passed, 945 assertions** (included in the focused total).
- `git diff --check`: passed. No frontend asset rebuild is required for these PHP-only behavior changes.

The four SQLite skips are the MySQL/MariaDB-only storage-engine check, two opt-in native Tesseract acceptance tests and the opt-in real Defender/EICAR test. No IDOR, tenant-isolation or private-file authorization test was skipped. Native OCR/Defender health is outside this audit.

Runtime JUnit evidence and discovered route coverage are written under ignored `storage/framework/testing/`; no customer data, keys or tokens are included in this report.

## Deployment and follow-up

The fixes need no database migration or frontend rebuild. Deploy the PHP changes through the normal reviewed release process and clear/rebuild application caches as usual. Do not reset customer roles, keys or storage.

After deployment, use controlled accounts from two companies to verify that a copied dashboard/document URL and substituted version/output IDs still return denial, that approved own files download, and that revoked management permissions stop API creation. Confirm the web document root exposes only `public/`, private storage is not mapped by the web server, and S3 blocks public access. These production checks were not performed by this local audit.

Run `php artisan test --filter=IdorSecurityTest` to repeat the route and object matrix. These tests are automatically included by the existing full SQLite/MySQL GitHub workflow; no separate opt-in job is needed. Run test suites only against an isolated test database because they use database reset fixtures.
