Per-Partner Folder Structures and the Root Layout
The folder tree is made or broken at one moment: ten to five on the afternoon a new partner goes live. That is when someone creates their folder by hand. Whatever is typed in that moment becomes permanent. If it is typed slightly differently from last time, the tree now has two conventions. By the tenth partner it has ten.
The cure is to stop creating partner folders by hand. A partner gets a root: one folder of their own, built from a template by a script. The script creates the same subfolders, sets the same rights, and refuses anything that breaks the naming rules. The script takes a partner code and produces a finished root in seconds, identically, every time. That is the whole subject of this article.
It is part of our Folder Taxonomy series. It covers the root layout and the home directory and jail settings that make the tree the only thing a partner can see. It explains which folder gets which right and gives the skeleton script for Windows and OpenSSH. And it gives the honest answer for the partner who needs two flows.
One Root Per Partner
A partner root is the single folder under partners/ that belongs to one external organization. It is named with that organization's short code and contains the four reserved folders from inbox and outbox conventions. Nothing about a partner lives anywhere else on the live server. The root is the unit of everything: one root, one account, one set of rights, one entry in the partner register. And when the relationship ends, there is one folder to retire.
The partner register is the list of every external organization you exchange files with. Each has a short code, an internal owner, and a contact. The partner code in the register is the root's name, and the register is the only place the code is invented. If a partner is not in the register, it does not get a root. This is a surprisingly effective way to find out that nobody owns the relationship.
Why one root and not one folder per flow at the top level? Because the root is a boundary three mechanisms can use. A jail can pin the account to it. Permissions can be set on it once and inherited below. And the damage a compromised partner credential can do stops at its edge, the argument made in the blast radius of one account. Three controls for the price of one mkdir.
The Root Layout
Here is the partners/ area of a synthetic distributor, Meridian Parts, with three partners. One of them has a second flow and a test root. Every root is the same shape, and the shape is exactly three levels deep: area, root, reserved folder.
D:\transfer\partners
├── acme
│ ├── inbox
│ ├── outbox
│ ├── processed
│ └── error
├── acme-returns second flow, second root, second account
│ ├── inbox
│ ├── outbox
│ ├── processed
│ └── error
├── acme-test test traffic, same shape, own account
│ ├── inbox
│ ├── outbox
│ ├── processed
│ └── error
├── bluewater
│ ├── inbox
│ ├── outbox
│ ├── processed
│ └── error
└── northgate
├── inbox
├── outbox
├── processed
└── error
The fixed depth is not an aesthetic choice. It lets a script check the whole tree with one pattern. It lets a nightly archive sweep find every processed folder without a list. And it lets a new administrator find any partner's inbox without asking. The moment one root is four levels deep, every one of those tools needs an exception. The exception needs a document, and the document needs an owner. Keep it three.
Home Directories and Jails That Match the Tree
Two settings on the partner's account turn the root into a wall. The home directory is the folder the account lands in after logging in. The jail, called a chroot on Unix-like systems from "change root", makes one folder appear to be the top of the whole server for that account. So the account cannot navigate above it, cannot see other partners' roots, and cannot discover that the server has any other folders at all.
The rule is one line: home directory equals partner root equals jail. The account logs in, lands in its root, sees four folders, and that is the entire server as far as it is concerned. The permission leaks described in why folder structure is a control stop being possible when this rule holds. That is because there is no path from one root to another.
On a Windows transfer server the two settings are usually properties of the account in the server's own configuration. They are a home folder, and per-folder permissions beneath it. Sysax Multi Server, for instance, gives each account a home folder and folder-level permissions. So the partner's root becomes the home. The rights matrix from the next section is applied to the four subfolders on the account itself. The file system permissions underneath then cover only the service and job accounts, which is what the script below sets.
On OpenSSH the jail is the ChrootDirectory directive, and it has a rule that catches everyone once. The jail folder, and every folder above it up to the file system root, must be owned by the superuser and not writable by anyone else. If the partner root is writable by the partner, the login fails with an unhelpful message. So on OpenSSH the root is owned by root with mode 755, and the partner gets rights on the four subfolders instead. This is why a chrooted partner cannot create folders at the top of their jail, a feature the taxonomy is delighted to accept. The rest of the OpenSSH side is in SFTP server configuration and account and jail hardening.
Remember: home directory = partner root = jail. The partner logs in, sees four folders, and cannot prove the server contains anything else. If a partner can type cd .. and see a listing, the tree is not doing its job, whatever the folder names say.
Which Folder Gets Which Right
The rights matrix below is the taxonomy's decision about who may do what in each folder. It is written as intent with the Windows and POSIX shorthand the script uses. How those mechanisms work, and the traps in inheritance, are explained in permission models explained and umask and inheritance gotchas. This article says which folder gets which right; that series explains how rights work.
| Folder | Partner account | Job account | POSIX owner:group mode |
|---|---|---|---|
root (acme) |
List only (RX) |
List only (RX) |
root:root 755 |
inbox |
Create, write, list, delete own (M) |
Read, move out, delete (M) |
acme:transferjobs 2770 |
outbox |
List, read (RX); delete if the platform can grant it alone |
Create, write, delete (M) |
svc_jobs:acme 2750 |
processed |
List, read (RX) |
Move in, move out (M) |
svc_jobs:acme 2750 |
error |
List, read (RX) |
Move in, write reason files (M) |
svc_jobs:acme 2750 |
One honest edge sits in the outbox row. The convention says a partner may delete a file after collecting it. On a POSIX file system, deleting a file requires write permission on its folder. That also allows creating files, so you cannot grant "delete but not create". The choice there is a read-only outbox plus an age-out job, which is what mode 2750 gives you. Windows access control lists can grant Delete separately from Create. So a Windows server can honor the convention exactly through the specific-rights form of icacls. Either way, the partner never gets a write right on the outbox, which is the property that matters.
The 2 at the front of the POSIX modes is the setgid bit. On a folder it makes every new file inherit the folder's group rather than the creator's. That is how a partner's upload into inbox ends up readable by the job's group without anyone running chgrp. It is one of those small settings that saves a ticket a week.
The Skeleton Script
A skeleton is the empty structure every new root starts from. The script that builds it is the most valuable template in this series. It turns "create the partner folder" from a judgment call into a command. Two versions follow, for a Windows server and for OpenSSH. Both validate the code against the naming standard, refuse to overwrite an existing root, and set rights on every folder they create.
Windows: New-PartnerRoot.ps1
The Windows version assumes the transfer server enforces the partner's own rights from its account configuration. So the file system needs to know only about administrators and the job account. If your server maps partners to real Windows accounts instead, pass the account with -PartnerAccount. The script then grants the partner column of the matrix as well.
param(
[Parameter(Mandatory)][string]$PartnerCode,
[string]$PartnerAccount = ''
)
$Base = 'D:\transfer\partners'
$JobAccount = 'MERIDIAN\svc_transferjobs'
$Reserved = 'inbox', 'outbox', 'processed', 'error'
# Naming standard rule 1: lowercase, digits, single hyphens.
if ($PartnerCode -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$') {
throw "Partner code '$PartnerCode' breaks the naming standard."
}
$Root = Join-Path $Base $PartnerCode
if (Test-Path $Root) { throw "Root already exists: $Root" }
# Build the skeleton.
New-Item -ItemType Directory -Path $Root | Out-Null
foreach ($d in $Reserved) {
New-Item -ItemType Directory -Path (Join-Path $Root $d) | Out-Null
}
# Root: stop inheriting from partners\, then grant explicitly.
icacls $Root /inheritance:r | Out-Null
icacls $Root /grant "BUILTIN\Administrators:(OI)(CI)F" | Out-Null
icacls $Root /grant "SYSTEM:(OI)(CI)F" | Out-Null
icacls $Root /grant "${JobAccount}:RX" | Out-Null
foreach ($d in $Reserved) {
icacls (Join-Path $Root $d) /grant "${JobAccount}:(OI)(CI)M" | Out-Null
}
# Partner rights, only when the partner is a Windows principal.
if ($PartnerAccount) {
icacls $Root /grant "${PartnerAccount}:RX" | Out-Null
icacls (Join-Path $Root 'inbox') /grant "${PartnerAccount}:(OI)(CI)M" | Out-Null
foreach ($d in 'outbox', 'processed', 'error') {
icacls (Join-Path $Root $d) /grant "${PartnerAccount}:(OI)(CI)RX" | Out-Null
}
}
Write-Host "Created $Root with folders: $($Reserved -join ', ')"
Write-Host "Next: create the server account with home = $Root and apply the rights matrix."
Read the icacls lines slowly, because they are where the security lives. /inheritance:r removes every inherited entry from the new root, so it stops receiving whatever partners\ happens to grant. The administrator grants use (OI)(CI) so they flow down to the four subfolders and their files. The job's and the partner's root-level grants deliberately have no inheritance flags, so each can list the root and nothing more. Their real rights are set per subfolder, exactly as the matrix says. The -cnotmatch is case-sensitive on purpose, since -notmatch would wave ACME through. Run it once with a test code and inspect the result with icacls D:\transfer\partners\acme-test /t before trusting it with a real partner.
OpenSSH: new-partner-root.sh
The OpenSSH version creates a real system user, because on that platform the partner is one. It follows the chroot ownership rule for the root and applies the POSIX column of the matrix to the subfolders. It assumes a group sftponly that the sshd_config Match block jails. It also assumes a group transferjobs for the job account, and a job user svc_jobs.
#!/bin/bash
set -euo pipefail
code="${1:?usage: new-partner-root.sh <partner-code>}"
base=/srv/transfer/partners
# Naming standard rule 1.
[[ "$code" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || { echo "bad partner code: $code" >&2; exit 1; }
root="$base/$code"
[[ -e "$root" ]] && { echo "root already exists: $root" >&2; exit 1; }
# Partner user: no shell, home is / because inside the jail the root IS /.
useradd --no-create-home --home-dir / --shell /usr/sbin/nologin --groups sftponly "$code"
# Root: owned by root, not writable by anyone else (ChrootDirectory rule).
mkdir -p "$root"
chown root:root "$root"
chmod 755 "$root"
# Reserved folders and the rights matrix.
mkdir "$root"/{inbox,outbox,processed,error}
chown "$code:transferjobs" "$root/inbox"; chmod 2770 "$root/inbox"
chown "svc_jobs:$code" "$root/outbox"; chmod 2750 "$root/outbox"
chown "svc_jobs:$code" "$root/processed"; chmod 2750 "$root/processed"
chown "svc_jobs:$code" "$root/error"; chmod 2750 "$root/error"
echo "created $root for user $code; set the key in the server's authorized_keys store"
The matching sshd_config block is four lines: Match Group sftponly, ChrootDirectory /srv/transfer/partners/%u, ForceCommand internal-sftp, and AllowTcpForwarding no. The %u expands to the username, which is why the script names the user after the partner code. The tree and the account share one name, and the jail follows without a per-partner line in the configuration. That is the layout paying for itself. Every folder from /srv down to the root must also be root-owned and not group-writable, or the jail refuses to open.
Neither script creates the partner's credential. That is on purpose: the skeleton is a taxonomy concern. Issuing a key or password is an account-lifecycle concern with its own handoff rules, covered in our transfer user lifecycle series. The script prints a reminder and stops.
The Partner Who Needs Two Flows
Sooner or later a partner needs to exchange two unrelated kinds of file: orders and returns, claims and remittances, data and reports. Is that one root or two? The taxonomy says two. acme and acme-returns are separate roots with separate accounts, built by the same script, each with its own four folders. The cost is one more account to manage. The benefit is that a credential leak, a quota, a cleanup rule, or an offboarding affects one flow and not the other. And no job ever has to guess which kind of file it is looking at.
The objection is always the same: "our software can only hold one login". Sometimes that is true. When it is, the fallback is one root and one inbox. The two flows are then distinguished by file name pattern and routed by the job on arrival, as described in routing files to destinations. Record it as an exception in the standard, with the partner's name and the reason. That way, the next administrator knows it was a decision and not an accident.
What you should not do is add a flow level inside the root, so that acme/orders/inbox and acme/returns/inbox sit side by side. It looks tidy. It also makes that one partner four levels deep and breaks the fixed depth that every script in this series relies on. And it puts a reserved name somewhere the standard says it may not be. Two roots or one inbox; never a deeper tree.
What Happens Without the Script
Northgate Retail, a synthetic retailer, had forty-one partner roots, each created by hand by whoever was on shift. One supplier's root had been created with the partner account granted modify on the root itself rather than on the inbox. That was because the person creating it was in a hurry and the inbox was "inside the root anyway". The supplier's automated client found it could create folders. It made a dated subfolder for every daily upload, as its default setting told it to. Northgate's collection job watched only inbox, which stayed empty. The supplier took the silence as acceptance. When the discrepancy surfaced fourteen months later there were four hundred and twenty dated folders in the root, none of them read. The postmortem's first recommendation was a script that made the mistake impossible rather than a memo asking people not to make it.
That is the case for the skeleton. A memo tells people the right shape. A script produces it, and refuses to produce anything else. Only one of the two still works at ten to five on a Friday.
Retiring a Root
A root built by the script is retired by the reverse process. It is short because everything about the partner is in one place. Disable the account first, so nothing new arrives. Wait a defined period, typically the flow's longest cycle, so in-flight files finish. Sweep processed and outbox to the archive. Confirm the four folders are empty, and remove the root. The partner's archive tree stays, subject to retention. The full checklist, including telling the partner and the internal owner, is in partner offboarding.
Compare that with retiring a partner on the Northgate server, where finding the folders was the first task and took a day. One root per partner makes offboarding a checklist instead of a search. It also makes the drift-detection script in documenting and enforcing the taxonomy a dozen lines long. That is because everything it checks has exactly one shape.
The Version to Tell a Colleague
Every partner gets one root, named with their code from the register, containing exactly four reserved folders and nothing else. Their account's home is the root, the jail is the root, and they cannot see anything above it. Rights are set by a script, never by hand, at two levels: the root and the four subfolders. A second flow is a second root. The whole design exists so that the tree, the account, and the script agree on one name and one shape. It also exists so that ten to five on a Friday is no longer a dangerous time.
The convention inside each root is in inbox and outbox conventions. The internal and project areas that sit beside partners/, with their own ownership rules, are in internal, department, and project folders. And the one-page standard that records the rules the script enforces is in the enforcement article that closes the series.
Frequently Asked Questions
Why is the partner root owned by root on OpenSSH instead of by the partner?
Can the partner create their own subfolders inside the inbox?
What is the setgid bit doing in the mode 2770?
Our transfer server has its own user database, not Windows accounts. Does the script still apply?
Should the test root be a real partner root or just a folder?
From the Sysax team: we build secure file transfer software for Windows. Sysax Multi Server is an FTP, FTPS, SFTP, and HTTPS server. Sysax FTP Automation handles scheduled, scripted transfers. Free trials are on the download page.
