Skip to content

Project Documents API Design

Ed Mozley edited this page Nov 12, 2026 · 2 revisions

Project documents in the REST API, and protecting documents from bulk theft - design

Status: built in 5.1.0 - user guide, developer guide, threat protection developer guide. Issue: #287 (thanks @cloudcogsio, who wrote the proposal this follows). Builds on Documents and the REST API.

The need

A project's plans, designs, sign-off letters and backout plans live in its Documents panel (document_links.parent_type = 'project'), and gate checklist items point at them. REST v1 covers almost everything else about a project but not its documents, so an integration that produces paperwork - a report generator, a PM tool, an AI agent through the MCP server - cannot file it on the project, and a client reading a project cannot see what is attached.

And documents are what an attacker wants. An API that can download them is also a way to take every contract, design and HR letter in one evening if a key leaks. So this ships with protection that makes bulk theft hard, noisy and short-lived.

Decisions (Ed, 12 November 2026)

  1. Per resource, on a shared service. /projects/{id}/documents now; the same shape can be added to other records later in a few lines each. A general /documents?parent_type= was not chosen: it would carry 18 different access rules in one route.
  2. Listing and filing use the project permissions (projects.read / projects.update). Downloading the bytes needs its own permission, documents.download, which no existing key has. A key built to file paperwork can never take it.
  3. The MCP project_overview tool lists document titles (never contents), only those the caller may see.
  4. All the security below in the first version.

Who a key can see - unchanged, and the first line of defence

Every API key acts as one analyst (api_keys.analyst_id) and can never see more than that person: their companies (narrowed again by the key's own company scope), their modules, the projects they may see (members-only included), and a document only when they can see at least one record it is attached to. A key made for someone in Facilities cannot reach HR's documents.

πŸ”‘ Make integration keys for a dedicated, limited analyst - never an administrator. An administrator's key sees what an administrator sees.

The routes

Route Permission Does
GET /projects/{id}/documents projects.read Paged list - id, kind (file/link), title, description, original name, MIME type, size, sha256, external URL, uploaded by/at, and content_url. Metadata only. ?client_reference= finds an upload without sending it again.
POST /projects/{id}/documents projects.update Multipart file (+ title, description, client_reference), or JSON external_url (+ title, description) for a link. 201 with the document; 200 for a retry with the same reference and the same bytes; 409 if the reference names something else.
POST /projects/{id}/documents/{document_id}/link projects.update Attach a document that already exists - the key's analyst must already be able to see it.
GET /projects/{id}/documents/{document_id}/content projects.read and documents.download The file, through the download guard below. A link returns its URL as JSON, never a redirect.
DELETE /projects/{id}/documents/{document_id} projects.update Removes the link, not the document; the last link soft-deletes it, as in the app.

A project outside the key's reach is 404, never 403, like every other project route. A document not on that project is 404 too - the content route never serves a document by id alone.

The rules do not change. Attaching runs the same checks as the app: documentCanWriteParent() before any file is written, attachmentAllowedTypes(), the size limit, uploadStoreFile(), the content hash, http(s) only for links, and search indexing and text extraction queued.

One service

The upload, link and unlink logic lives inline in api/documents/save.php, attach.php and unlink.php today. It moves to DocumentsService (includes/services/documents.php) - add(), attach(), detach(), listFor() - used by those endpoints and by REST v1, so the browser and the API cannot drift. The browser endpoints behave exactly as before.

The document's company is the project's company (the browser upload uses the analyst's active company, which is the same thing there).

Protecting documents from bulk theft

1. Its own download permission

documents.download - shown as Download document contents on System β†’ API. Not part of any preset, not granted to any existing key, and named plainly so nobody ticks it by accident.

2. Every API download is recorded

document_access_log gains api_key_id and bytes. Every API download (and every refusal by the guard) is a row: which key, which analyst, which document, which IP, how many bytes. The key's row on System β†’ API shows its recent downloads.

3. The download guard - limits per hour, with alerts

System β†’ API β†’ Threat protection (administrators), with a per-key override:

Setting Default What happens
Alert after 50 files or 500 MB in an hour Administrators get an email and a bell notification - once per key per hour, not once per file.
Stop at 200 files or 2 GB in an hour Further downloads by that key are refused (429 download_limit) until the hour has passed.
When the limit is reached Refuse until the hour passes Or Switch the key off - the key is deactivated on the spot and must be switched back on by an administrator.

Counted over the last 60 minutes, from document_access_log, per key.

4. Allowed addresses, per key (optional)

A list of IP addresses and ranges (203.0.113.7, 10.20.0.0/16, IPv6 too). When set, a request from anywhere else is refused (403 ip_not_allowed) before anything else runs - the strongest protection against a stolen key, because the thief is not on your network. Blank = any address, as today.

5. Allowed hours, per key (optional)

Days and a time window in the server's time zone - Mon-Fri, 07:00-19:00. Outside it, either every request is refused (403 outside_allowed_hours) or - alert only - allowed with an alert to administrators. An integration that only runs in office hours cannot be used at 03:00. Blank = any time, as today.

6. An alert when a key is used from a new address

api_keys.last_used_ip is already recorded. When a key is used from an address it has not used in the last 30 days, administrators get a bell notification (and an email, if the setting says so). Not a block - a heads-up that a key may have been copied.

7. One security log

api_key_events - one row per alert or refusal (download_alert, download_limit, key_suspended, refused, ip_refused, outside_hours, new_ip, auth_failed, address_blocked) with the key, the address and a detail. Shown on System β†’ API β†’ Threat protection, newest first, so an administrator sees the whole story of a key in one place. Alerts are de-duplicated through it (one per key per event per hour).

8. Probing and key guessing (added during the build, from Ed's list)

  • Probing - a key refused (403/404) again and again: alert at 20 in 10 minutes, switch the key off at 100. Because "not yours" and "does not exist" are answered alike on purpose, this one counter is also how enumeration of ids shows.
  • Key guessing - an address sending missing or wrong keys: 50 in 15 minutes blocks it from the API for 15 minutes, with an alert.
  • Allowed hours: alert only as well as refuse.
  • Not built: access from an unexpected country - it needs a licensed geo-IP database that goes stale on offline installs, and VPNs and cloud hosts defeat it; allowed addresses and the new-address alert do the job better. Response-size jumps - documents are covered by the MB limit, and every other route is paged.

9. Administrator keys and the activity log (added during the build)

  • Keys that act as an administrator - a setting rather than a ban (analyst provisioning needs one): allowed with a warning (default), only with allowed addresses, or not allowed; checked on every request.
  • The activity log - System β†’ API β†’ Activity, one row per call (never contents), successes optional, kept N days, then deleted or zipped (a zip per day / week / month, named by first and last record) by a daily background job on the Background jobs report.

What we deliberately do not build

  • No bulk download. One document per request, only through its project. No zip, no "all documents" route.
  • No presigned or public links. Every byte goes through FreeITSM's checks.

Data

ALTER TABLE documents
    ADD client_reference VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
    ADD UNIQUE KEY uq_documents_client_reference (uploaded_by_id, client_reference);

ALTER TABLE document_access_log
    ADD api_key_id INT NULL,
    ADD bytes BIGINT NULL,
    ADD KEY idx_document_access_key (api_key_id, created_datetime);

ALTER TABLE api_keys
    ADD allowed_ips TEXT NULL,             -- one address or range per line; NULL = any
    ADD allowed_hours TEXT NULL,           -- JSON {"days":[1..7],"from":"07:00","to":"19:00"}; NULL = any time
    ADD download_alert_files INT NULL,     -- per-key overrides; NULL = the System -> API -> Security values
    ADD download_limit_files INT NULL,
    ADD download_limit_mb INT NULL;

CREATE TABLE api_key_events (
    id INT NOT NULL AUTO_INCREMENT,
    api_key_id INT NULL,
    event VARCHAR(30) NOT NULL,
    ip_address VARCHAR(45) NULL,
    detail VARCHAR(500) NULL,
    created_datetime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_api_key_events_key (api_key_id, event, created_datetime),
    CONSTRAINT fk_api_key_events_key FOREIGN KEY (api_key_id) REFERENCES api_keys (id) ON DELETE SET NULL
);

Install settings: api_download_alert_files (50), api_download_alert_mb (500), api_download_limit_files (200), api_download_limit_mb (2048), api_download_limit_action (refuse / suspend), api_new_ip_alert (bell / bell_email / off).

Care needed

  • Order of checks on every request: key valid β†’ allowed address β†’ allowed hours β†’ rate limit β†’ route permission β†’ the record. The address and hours checks come first so a refused request learns nothing.
  • The guard runs before the file is read, and a refused download is logged as a refusal, not a download.
  • Alerts go to active administrators. Email uses the first send-capable mailbox; no mailbox = bell only, and the security log says so.
  • A suspended key says why on System β†’ API, with the time and the event.
  • The address is REMOTE_ADDR, the connection's own - never X-Forwarded-For, which the caller writes (an allow-list read from it would be one header away from useless). Behind a reverse proxy the list must name the proxy.

Tests

tests/project-documents-api.php - list, upload, link, attach, download, remove; retries; 404s for another project's document and an unreachable project; a key without documents.download refused; the guard's alert and limit (and suspend); allowed addresses and hours; new-address alert; the browser endpoints unchanged.


See also: Documents Β· REST API: Projects Β· Security

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally