Skip to content

Security

The short version

  1. Never expose Tenantry raw. Put it behind a private network, your WAF or reverse proxy, Cloudflare Tunnel with Access, or Teleport. See Exposure: never expose raw.
  2. Secrets live in files, never on command lines. Bot join token, Cloudflare tunnel token and DNS API credentials are read from files (mode 0600/0400) or typed hidden; the installer copies them where needed and tells you to delete yours.
  3. Bot join token: single-use, handed to tbot through a 0400 file and deleted after the first join. With --teleport-local it never leaves the installer: it is read from tctl, never printed, never in argv.
  4. Tunnel token: stored only in <dir>/cloudflared/tunnel-token (0400, uid 65532), read by cloudflared through TUNNEL_TOKEN_FILE. Never in .env, a compose file or a bundle.
  5. DNS credentials: only in <dir>/secrets/acme-dns.env (0600).
  6. Tenantry holds a short-lived Teleport identity renewed by Machine ID, with a minimal role by default (no role, token or lock rights; --bot-role full is opt-in). Join tokens are created by administrators only: client scoping cannot constrain them.
  7. Releases are pinned and verifiable: install.sh needs an explicit --version, pins the image by digest and checks its cosign signature when cosign is present (--require-signature to make it mandatory).
  8. Client servers must reach two paths (/api/callback, /api/i) that answer only to one-time, unguessable ids. Bypass Access for those paths only.
  9. Back up .env and the /data volume together (Backups).

The rest of this page is the detailed threat model and audit.

Threat model and audit

Audit of the tenantry-v2 branch (Fastify backend, packages/shared, Dockerfile, docker/*.yml, scripts/install.sh, script templates). Method: STRIDE per component, OWASP ASVS level 2 and OWASP Top 10 as a reading grid, manual code review, one npm audit --omit=dev. Features still under development (self-hosted Teleport cluster bundle, Steward instance bundle, Teleport SSO login via JWT, audit log) are not audited line by line: the section "Requirements for new features" sets out what they must comply with.

Status: see the "Status" column of each finding (rows marked "fixed" are fixed in the code of this branch).

1. Architecture and trust boundaries

 [Admin browser] --TLS--> [Cloudflare Access / WAF / reverse proxy / Teleport App Access] --> [Tenantry :8443 (container)]
                                                                         |  execFile (argv, no shell)
 [Windows DC]  --irm / POST callback (token in URL / Bearer)------------>|  --> tctl --> [Teleport cluster]
 [Linux gateway] --curl|bash, GET ad-output ---------------------------->|
Boundary Who crosses it Current authentication
F1 Internet / internal network to the UI Administrator Signed session cookie + CSRF (double-submit)
F2 Client machines to /api/i/* and /api/callback/* DC, Linux gateway (not under our control) Per-deployment bearer token (256 bits), in the URL or in Authorization: Bearer
F3 Tenantry to cluster tctl Identity file renewed by Machine ID (tbot sidecar, recommended), tctl auth sign file, or local admin mode
F4 Generated scripts to client machines Code run as root / domain administrator TLS only, no signature
F5 Tenantry to Steward consoles (outbound) Poller, every 60 s https only, TLS verified (optional pinned CA per console), per-console bearer token stf_…
F6 Tenantry to notification receivers (outbound) Alert engine https only, TLS verified, optional HMAC signature (webhook) or bearer token (ntfy)

Key point: Tenantry produces code that runs with the highest privileges (domain administrator on the DC, root on the gateway) and holds a tctl identity able to create tokens and roles. Compromising it is therefore equivalent to compromising the client's AD and the cluster.

2. Assets

Asset Sensitivity Where
tctl identity (file or Teleport data directory) Critical container volume
windowsdesktop join token (60 min, multi-use until revoked) High memory (kept only to revoke it), served install.sh script
Application join token for a client proxy (10 or 30 min, multi-use until revoked) High memory (kept only to revoke it), operator copy
Deployment token (URL key + callback Bearer) High memory, displayed URLs, logs (see SEC-02)
Argon2id admin hash, SESSION_SECRET High .env (0600)
Teleport certificate authority pushed by GPO to the domain Critical (integrity) generated scripts
AD output (SID, LDAP address, LDAPS CA) Medium (mainly integrity) memory
SECRETS_ENCRYPTION_KEY High (decrypts the secrets below) .env (0600)
Console fleet tokens (stf_…, read access to each client console status) High /data/consoles.json, AES-256-GCM
Alert channel URLs (Teams/Slack/webhook keys), HMAC secrets, ntfy tokens Medium /data/alerts-settings.json, AES-256-GCM
Client register (names, contacts, notes) Medium (personal data of client contacts) /data/clients.json

3. STRIDE threats per component

Component S (spoofing) T (tampering) R (repudiation) I (information disclosure) D (denial of service) E (elevation of privilege)
Login / session Brute force (SEC-05), default dev password (SEC-06). Mitigated: Argon2id, constant-time comparison, HttpOnly/SameSite=Strict/Secure cookie Signed cookie, opaque 256-bit identifier Successful logins not logged (SEC-16) — 64 MiB body before authentication (SEC-03) Single account, no roles
Admin API CSRF mitigated (double-submit + SameSite) TypeBox validation + shared business validation Minimal log (SEC-16) Detailed error messages (SEC-18) tctl launched on every check request tctl create -f overwrites (SEC-10)
/api/callback/* Anyone holding the token can impersonate the DC (SEC-01, SEC-02) Repeated rewriting of the AD output, YAML injection (SEC-01) Source IP not kept in the deployment Token in logs (SEC-02) Chunks with no cap (SEC-12) Untrusted content that ends up as root on the gateway (SEC-01)
/api/i/* (scripts) Stolen token = scripts and join token (SEC-02) No signature (SEC-07) Downloads not explicitly logged Join token in install.sh, no Cache-Control (SEC-15) 60 requests/min/IP __proto__ key (SEC-14)
Script generation — Context-specific escaping; gaps: PowerShell typographic quotes (SEC-08), YAML label key (SEC-09) — — — Code injection on DC / gateway
TeleportCommandService — execFile, fixed argv, validated values: good — tctl output filtered by scrubSecrets 15 s timeout, 8 MiB maxBuffer Tenantry role too broad (SEC-11)
Container / install — Unpinned image (SEC-17) — Identity at 0644 (SEC-11) No resource limits (SEC-17) --auth local: root + write access to Teleport data (SEC-11)

Controls already in place and verified: no shell (execFile, shell:false), binaries by absolute path in production, crypto.randomBytes(32) token, constant-time comparison, helmet + strict CSP (script-src 'self'), no CORS (same origin), HSTS in production, global and per-route rate limiting, TTL on sessions and deployments, secret redaction in logs, non-root container, read-only filesystem, cap_drop: ALL, no-new-privileges, port published on 127.0.0.1, .env secrets at 0600, admin password passed to the hash function via stdin, input validation on install.sh, refusal to overwrite Teleport resources not created by the tool (route side).

4. Findings

Severity: Critical > High > Medium > Low > Info. Confidence is indicated when it is not "high".

ID Sev. Location Scenario Fix Status
SEC-01 High services/adOutput.ts:57-67, schemas.ts:109-127, templates/install.sh.tmpl:219-227 The AD output posted by the "DC" is only partially checked: serverName merely has to be non-empty, ldapUsername has to contain a \. install.sh inserts them with a plain str.replace into server_name: '${LDAP_SERVER_NAME}': an apostrophe followed by a newline injects YAML keys into /etc/teleport.yaml (root, gateway). Even without injection, a token holder can post their own IP:636 address, their own CA and their own SID, and the output can be rewritten at will until expiry. Validate strictly: serverName FQDN (isValidFqdn), ldapUsername ^[A-Za-z0-9-]{1,15}\\[A-Za-z0-9._$-]{1,64}$, netbiosDomain NetBIOS, serverOus: validated DNs under the domain. Reject the ' character and control characters. In install.sh, escape before substitution (double ', reject \n\r). Refuse a second write (or require admin confirmation in the UI before GET ad-output serves the data). Do not persist arbitrary adValues: keep only known keys. fixed
SEC-02 High app.ts:37-45, logger.ts:12-25, routes/serve.ts:54-75, routes/deployment.ts:159-186, DeploymentSessionStore.ts:73-92 The deployment token is in the path /api/i/:id/:key/:file. Fastify request logging (pino instance supplied, disableRequestLogging not enabled) may write the URL, and therefore the token, to the container and reverse proxy logs; it also ends up in shell history, PowerShell history and script block logging (4104). That same token is used to download install.sh, which contains the join token, repeatedly for 120 min (join token: 60 min). Anyone who reads a log can therefore enroll a fake gateway under the customer's client_code. Confidence: medium on whether the URL actually appears in the log (depends on the pino serializers), high on the rest. Fastify({ disableRequestLogging: true }) and an onResponse hook that logs the masked path (/api/i/:id/***/:file), or a req serializer that replaces the key segment. Separate two secrets: download key (single use or short TTL, 15 min) and callback token (used only for the callback POST/GET). Make install.sh single-use (mark joinTokenFetched, refuse the second GET); expire the deployment on the first successful install.sh. Ask operators to mask the path in proxy logs. fixed: disableRequestLogging + onResponse log with masked path (/api/i/:id/***/:file); download key separate from the callback token (the callback token appears in no URL), valid 60 min at most; install.sh served only once (410 afterwards), then the join token is removed from memory. Left to the operator: mask the segment in the proxy, client workstation histories
SEC-03 High app.ts:43, config.ts:220, auth/authPlugin.ts:53-62, routes/callback.ts:62-70 Global bodyLimit = 64 MiB (CALLBACK_BODY_LIMIT_BYTES). Fastify reads, parses (JSON) and validates the body before preHandler: /api/auth/login, all admin routes and ad-output therefore accept 64 MiB of JSON without authentication, 100 times per minute per IP. requireAuth and authOr401 run too late. Global bodyLimit: 256 * 1024; raise the limit only on ad-output and bring it down to a realistic value (the script caps at 1 MiB, MAX_CERT_BYTES 48 MiB is pointless). Convert requireAuth and authOr401 into onRequest hooks (before the body is read). fixed: global bodyLimit 256 KiB; ad-output 4 MiB (CALLBACK_BODY_LIMIT_BYTES), MAX_CERT_BYTES 1 MiB; global authentication guard in onRequest (admin) and Bearer check in onRequest (callback): no unauthenticated body is read
SEC-04 High package-lock.json: fastify 5.10.0, @fastify/static 8.3.0, find-my-way 9.6.0 npm audit --omit=dev: 10 alerts, 9 of them high. Relevant ones: Fastify (X-Forwarded-* spoofing with trustProxy, schema validation bypass, authentication bypass via a malformed URL to an encapsulated notFound), @fastify/static (path/route guard bypasses) which serves the SPA, brace-expansion (DoS). Vue/postcss: build only. npm audit fix, then @fastify/static at a fixed version (next major); rerun the audit in CI (npm audit --omit=dev --audit-level=high) and pin Dependabot/Renovate. fixed: npm audit fix + @fastify/static 10.1.6, fastify 5.12.7; npm audit --omit=dev = 0 alerts. Only development alerts remain (vitest/esbuild/tinypool, no production impact)
SEC-05 Medium auth/authPlugin.ts:64-98, plugins/security.ts:35-38, app.ts:44 No login-specific limit: 100 attempts/min/IP (each costs a 64 MiB Argon2id), no lockout or progressive delay. With trustProxy: true, the IP used is the one from X-Forwarded-For: if the proxy appends to the header instead of replacing it, the attacker sets the rate-limit key and bypasses everything. Dedicated limit on /api/auth/login: 5/min/IP and a global counter (single account) with exponential delay; trustProxy limited to the actual hop (1 or the proxy's IP list); log IP and failure; document MFA via Teleport App Access. fixed: 5 attempts/min/IP on /api/auth/login, per-name counter (fingerprint) with exponential lockout from 30 s to 15 min (429 + Retry-After, auth.lockout event), TRUST_PROXY (hops or CIDR, default none; true refused). MFA: via Teleport App Access / SSO JWT
SEC-06 Medium config.ts:162-182, 241-246, .env.example:111,125,131,183, scripts/hash-password.ts:10-24 Any NODE_ENV other than production enables a plaintext ADMIN_PASSWORD with a fallback to admin, a session secret generated with Math.random and LOG_LEVEL=debug. .env.example ships NODE_ENV=development, ADMIN_PASSWORD=change-me-in-dev and PUBLIC_API_BASE=http://. An instance copied from the example and exposed is wide open. hash-password.ts echoes the typed password and applies trim() (the login does not: a password with a trailing space will never log in). Refuse to start if NODE_ENV!=production and HOST!=127.0.0.1/localhost; remove the 'admin' fallback (require ADMIN_PASSWORD); crypto.randomBytes for the ephemeral secret; .env.example in production mode with empty values; masked input and no trim(). Minimum length (12) also enforced on variable initialization. fixed: startup refused outside production if HOST is not local; no more default password (ADMIN_PASSWORD or hash required); ephemeral secret via crypto.randomBytes; hash-password without trim() and with masked input; .env.example in production with https URL
SEC-07 Medium routes/deployment.ts:169-177, templates/install.sh.tmpl:185-186, templates/prepare-local-windows.ps1.tmpl:22-23,80-81, config.ts:208-211, scripts/install.sh:50 irm … -OutFile + -ExecutionPolicy Bypass and curl … | sudo bash have no integrity check; TLS is the only protection (customer's inspection proxy, third-party CA). The Linux script downloads and runs https://cdn.teleport.dev/install.sh without a checksum; the Windows script downloads an .exe without checking hash or Authenticode signature. PUBLIC_API_BASE and --public-url accept http://: the token would travel in clear text. An interrupted curl | bash can run a prefix of the script. Return the SHA-256 of each script to the UI and display "download, verify, run" commands (sha256sum -c, Get-FileHash). Wrap install.sh in a main function called on the last line. Verify the SHA-256 of the Teleport downloads (value supplied by Tenantry, taken from tctl/the pinned version) and Get-AuthenticodeSignature for the .exe. Refuse http:// in production (config + install.sh), add --proto '=https' --tlsv1.2 to the curl calls. Option: sign the .ps1 files (Authenticode). fixed (SHA-256 of the Teleport tarball read from the CDN: out-of-band pinning via TELEPORT_TARBALL_SHA256; Authenticode signature of the .exe as a warning)
SEC-08 Medium services/generators/scriptGenerators.ts:23-25, shared/src/normalize.ts:238-257, shared/src/validate.ts:88, schemas.ts:87-91 psLit only doubles the ASCII apostrophe. PowerShell also treats ‘ ’ ‚ ‛ (U+2018/2019/201A/201B) as single quotes: a value containing ’ breaks out of the literal. Affected values are loosely constrained: serviceAccount, OU DNs (parseDn accepts almost anything), allowedRdpGroups. A group name pasted from Word is enough to break the script, and a third party who feeds the value to the administrator gets execution as domain administrator. In psLit, also double these four characters (or reject them) and refuse control characters; validate serviceAccount (^[A-Za-z0-9._-]{1,20}$), groups (^[^\x00-\x1f'‘’‚‛"$]{1,256}$`) and the value of each RDN in a DN. Add regression tests with these characters. fixed
SEC-09 Medium shared/src/yaml.ts:200-202, shared/src/validate.ts:63, schemas.ts:37-48,72, templates/install.sh.tmpl:193 The hostLabels keys are written without quotes in the gateway YAML (YAML key injection via line break; 128 characters with no pattern). dataDir is only validated by startsWith('/'): install -d -m 0700 "${DATA_DIR}" changes the mode of an existing directory (/, /etc), and a control character causes a 500 error instead of 422. yamlSingleQuote(k) for keys + pattern ^[A-Za-z0-9_./-]{1,64}$; validate dataDir (^/(var/lib|opt)/[A-Za-z0-9._/-]{1,100}$, no ..) and make it fail with 422. fixed
SEC-10 Medium services/TeleportCommandService.ts:296-340,322, routes/steward.ts:100-115 tctl create -f <file>: in tctl, -f is --force (the file is positional), so creation always overwrites. The "no overwrite" protection exists only in the route and fails open: getResource returns null if tctl get fails or times out, which is read as "the resource does not exist". A race between check and write is possible. Confidence: medium (to be confirmed against the bundled tctl version). Call tctl create <file> without -f (native failure if the resource exists) and --force only when overwrite=true after verification; treat a tctl get error other than "not found" as a failure (409/502), not as absence. fixed: tctl create without --force unless the resource is verified to exist and be replaceable; getResourceLabels/getApp/resourceExists distinguish "not found" from a tctl error (resource_state_unknown error, 502) and apply refuses to write when existence is indeterminate
SEC-11 Medium shared/src/steward.ts:197-214, docker/compose.auth-local.yml, docker/compose.auth-identity.yml, scripts/install.sh:65,68 The tenantry role grants create/update on role: that is the right to modify any existing role (including the administrators' one), i.e. cluster administration. --auth local mounts /var/lib/teleport writable with user: root. install.sh recommends chmod 644 on the identity file, readable by any local user of the host. Split Tenantry role in two: routine functions (tokens, inventory, read) permanently; role/app right only for the duration of the integration (short-TTL identity, tctl auth sign --ttl=1h). Identity file 0400 owned by UID 1000 rather than 644. Discourage --auth local (keep the warning, require --i-understand-the-risk). fixed for the role (update on role absent unless allowRoleUpdate, warning header); remaining: identity at 0644 with --auth identity and --auth local (scripts, compose); --auth machine-id (recommended) avoids both: the file is written by tbot (uid 1000, 0600) into a volume Tenantry mounts read-only
SEC-12 Medium routes/callback.ts:36-48,110-215 Chunk storage has no cap: free-form uploadId ([0-9a-fA-F-]{16,64}), up to 512 chunks of 1 MiB per upload, 1000 requests/min, sweeping only on a request. A token holder can exhaust memory (several GiB). The reassembled payload is then read by JSON.parse without the AdOutputSchema schema; a non-string field causes a 500. Limit to N uploads in progress per deployment (e.g. 2) and to a total byte count (e.g. 4 MiB); check the cumulative size on each chunk; validate the payload with Value.Check(AdOutputSchema, parsed); periodic sweep by timer. fixed: at most 2 uploads in progress per deployment (64 in total), cumulative byte cap per upload, timer sweep, reassembled payload validated by AdOutputSchema (400 otherwise), second write of the AD output refused (409) for the callback; the authenticated administrator can correct it through manual entry
SEC-13 Low templates/01-configure-ad.ps1.tmpl:62-77 Export-LdapsCaPem retrieves the LDAPS chain with a callback that always returns $true, then publishes it as a trusted CA (GPO / callback). A man in the middle on the DC–LDAP segment at the time of the script substitutes their CA. Validate that the chain ends at a root of the DC's local store (local store already given priority), display the SHA-256 fingerprint and ask for confirmation. fixed
SEC-14 Low routes/serve.ts:33,59-60 FILES[file] on a plain object: file=constructor or __proto__ yields a truthy entry without gen, hence TypeError → 500 whose message is returned by the default error handler. Access after token authentication. Object.hasOwn(FILES, file) or Map. fixed: Object.hasOwn(FILES, file) (404) and generic error handler for 5xx
SEC-15 Low routes/serve.ts:75, routes/callback.ts, plugins/security.ts:22,42-51, auth/authPlugin.ts:64,100 No Cache-Control: no-store on responses containing a token or the AD output (a proxy caches them). style-src 'unsafe-inline'. Login and logout without CSRF (login CSRF, forced logout). No __Host- prefix for the cookie, no "log out all sessions", no session limit. Global no-store header on /api/*; __Host-tb_session cookie (production); CSRF on login/logout or a verified Sec-Fetch-Site/Origin header; remove unsafe-inline once the new front end ships (hashes/nonce). fixed: Cache-Control: no-store on all /api/* responses; __Host-tb_session and __Host-_csrf cookies in production; CSRF required on login and logout; POST /api/auth/logout-all; 10 sessions maximum; style-src 'self' (the Vite build emits no inline style)
SEC-16 Low auth/authPlugin.ts:82-97, routes/steward.ts, routes/serve.ts No audit log: successful logins, logouts, steward/apply (who, what, overwrite), token creation, script downloads (IP, user-agent), callback receipts (IP) are not traced in a structured way. login failed logs the typed username as is (a password typed in the wrong field ends up in the logs). Structured audit events {ts, actor, ip, action, target, outcome} without secrets, append-only writes, rotation, hash chaining; do not log the invalid username (or log its hash). fixed: chained audit log (see the section "Teleport authentication (SSO) and audit log"), invalid username never logged. File rotation not handled (a truncation breaks the chain: archive, then start from a fresh file)
SEC-17 Low Dockerfile:3,15,32, docker/compose.yml Base images by floating tag (node:22-bookworm-slim, debian:bookworm-slim); tctl tarball checksum fetched from the same CDN as the archive (detects corruption, not compromise) when TELEPORT_SHA256 is empty; no mem_limit/pids_limit/cpus; HOST=0.0.0.0 in the container: with compose.join-network.yml, the other containers on the network reach the port without going through the proxy. Pin by digest; make TELEPORT_SHA256 mandatory in install.sh (superseded: tctl is now compiled from the Teleport source tag, pinned with TELEPORT_COMMIT / --teleport-commit); mem_limit: 512m, pids_limit: 128; firewall between containers of the joined network; HEALTHCHECK already present. to fix
SEC-18 Low services/TeleportCommandService.ts:56,445,511, routes/guided.ts Errors return String(err): binary path and the process PATH. Disclosure limited to the administrator. Generic messages on the client side, detail in the log. partially fixed: binary path and PATH removed from errors, generic messages for inventory and desktops, generic 5xx errors on the client side. routes/deployment.ts remains (generation_failed message filtered by scrubSecrets, out of scope of this fix)
SEC-19 Info services/PackageBuilder.ts:104-106, services/ZipService.ts ZIP generation code not called by the current routes. If re-enabled: secrets/token in the archive and checksums.sha256 stored inside the archive (no integrity guarantee). Remove the dead code or publish the fingerprint out of band. fixed (PackageBuilder removed; ZipService kept for bundles, without a checksums file in the archive)

Items verified with no finding

  • SSRF: the only outbound HTTP requests are the console poller and the alert notifications (section 11), to targets set by an administrator; steward never contacts appUri.
  • No path traversal: template files are read by fixed name, serve.ts resolves no disk path.
  • No argument injection into tctl: clientCode validated (isValidClientCode), TTL bounded, resource names filtered by regular expression.
  • Token entropy and comparison are correct (32 random bytes, constant-time comparison, fixed length).
  • Session fixation: a fresh identifier is issued on each login, logout destroys the session on the server side.
  • Validation regular expressions have no obvious catastrophic pattern.

5. Residual risks (to accept or handle through operations)

  1. Tenantry = key to the cluster and to the client's AD. Even with the fixes, compromising the process yields a live tctl and the ability to serve booby-trapped scripts (CA pushed by GPO, code as domain administrator). Reduce exposure (network, Teleport MFA) rather than pretend to eliminate it.
  2. Bearer token in the URL. Convenience of irm | iex / curl | bash versus the risk of leaking through the client's intermediate logs. To be offset by a short TTL and single use (SEC-02).
  3. Sessions and deployments in memory. A restart loses them (acceptable); no cap on the number of deployments created by the administrator.
  4. Single administrator account, no MFA of its own. To be protected by Teleport Application Access (session MFA) or an identity proxy.
  5. Dependency on cdn.teleport.dev for tctl, the Linux installer and the Windows setup.
  6. Join tokens live until revoked or expired. Tenantry revokes them itself (tctl tokens rm, audited token.revoke): the windowsdesktop token as soon as the desktop check passes for a gateway that fetched install.sh and is connected, when the operator finishes or abandons the guided install, or by the sweeper when the deployment expires; a client proxy token as soon as the verification shows an application service serving the console. Until then the token value stays in Tenantry's memory (never served again, never logged) so it can be revoked. Remaining window: between the join and the revocation, after a failed revocation (the UI shows the manual tctl tokens rm command, the audit records outcome=error), and after a Tenantry restart (memory lost: the token lives until its TTL, 60 minutes at most). The identity needs delete on token (see Tenantry role).
  7. The Teleport CA is trusted at domain level by the AD script; a generation error has a domain-wide effect (the script offers -WhatIf and rollback-ad.ps1).

6. Requirements for new features

  • Teleport SSO login (Teleport-Jwt-Assertion JWT header): verify the signature with the proxy's JWKS (fetched only from the configured address, TLS verified, cache with expiry), allow-listed algorithm (RS256), iss, aud equal to the application URI, exp/nbf with 30 s tolerance, required role in roles. Accept the header only if the request comes from the proxy/agent (listening on 127.0.0.1/a dedicated network), never from an arbitrary client. Create a dedicated session and keep password mode disableable.
  • Audit log: see SEC-16; include sub and the Teleport role name in actor.
  • Self-hosted Teleport cluster bundle / Steward instance bundle: generate secrets (passwords, keys) with crypto.randomBytes, do not log them or put them in a URL, deliver secret files at 0600, provide out-of-band checksums, escape each value in the target context (YAML, .env, shell, PowerShell) with the same tools as escape.ts, test injection (apostrophe, newline, $(), typographic quotes).
  • New frontend: no raw HTML rendering (dangerouslySetInnerHTML/v-html) for values coming from the callback (domain name, LDAP account); CSP without unsafe-inline.

Tenantry credentials: short-lived certificates renewed by Machine ID

Tenantry's tctl identity is its most sensitive secret (it can create roles, tokens, locks: see Tenantry role). --auth machine-id (Install options) is the recommended mode:

  • Short-lived: tbot issues certificates valid for certificate_ttl (1 h by default) and renews them every renewal_interval (20 min). A stolen identity file is useless after at most one hour; nothing long-lived sits on disk for Tenantry.
  • The join token is single-use and never stored durably: the administrator types it at a prompt into a 0600 file (umask 077, never in argv or shell history), install.sh copies it (0400, uid 1000) into a folder mounted read-only into tbot only, then shreds both copies after the first join. It never reaches Tenantry container, .env or logs.
  • Separation: tbot holds the bot's renewable identity in its own volume (tbot-storage), which Tenantry does not mount. Tenantry mounts only the output volume, read-only. A compromised Tenantry can use the current certificate (as with any identity file) but cannot renew it once tbot is stopped, nor mint a long-lived one.
  • Renewal monitoring: Tenantry reads the identity file's modification time (never its key); if it is not rewritten within 2 x renewal_interval, the dashboard and Security page warn and the identity_not_renewed alert fires.
  • Revocation: lock the bot (tctl lock --user=bot-tenantry) or delete it (tctl bots rm tenantry): renewal stops, the current certificate expires within certificate_ttl. Teleport also refuses the renewal of a bot identity used from two places (generation counter): never copy tbot-storage.
  • Containers: tbot runs as uid 1000, read-only root, cap_drop: ALL, no-new-privileges; the one-shot tbot-init only has CHOWN and no network. The image is the official tbot-distroless pinned to the cluster version.

With --auth identity, choose the tctl auth sign --ttl knowingly: the file lives until it expires and is renewed by hand.

7. Operations checklist

Network - [ ] Publish Tenantry only through Cloudflare Tunnel with Access, Teleport Application Access, a VPN, or a WAF / TLS reverse proxy; never expose 8443 directly. install.sh --expose has no raw mode. - [ ] Allow from the Internet without login only /api/callback/* and /api/i/* if the client machines are off-network (with Cloudflare Access: a Bypass application for exactly these two paths); the rest (/, /api/auth/*, /api/deployment/*, /api/steward/*, /api/teleport/*) is restricted to administrators. - [ ] The proxy replaces X-Forwarded-For (no appending) and masks the key segment in its access logs for /api/i/. - [ ] PUBLIC_API_BASE in https:// with a certificate trusted by the DC and the gateway. - [ ] Attach compose.join-network.yml only with a firewall between containers.

Secrets - [ ] Delete the token files you created (bot join token, tunnel token, DNS credentials) once install.sh has copied them; the installer reminds you. - [ ] NODE_ENV=production, ADMIN_PASSWORD_HASH (Argon2id) and SESSION_SECRET ≥ 32 random characters; .env at 0600, dedicated owner, excluded from unencrypted backups. - [ ] Administrator password of at least 14 characters, rotated at each departure; --reset-secrets after any suspicion. - [ ] Short-lived tctl identity: --auth machine-id (1 h certificates renewed by tbot), limited role; avoid --auth local. Watch the identity_not_renewed alert. - [ ] With --build, set --teleport-commit (TELEPORT_COMMIT: commit SHA of the Teleport tag, obtained from a trusted source). - [ ] SECRETS_ENCRYPTION_KEY present (generated by install.sh), distinct from SESSION_SECRET; after a suspected leak of .env: --reset-secrets, then revoke and re-issue every console fleet token and channel secret. - [ ] Egress: allow Tenantry container to reach only the consoles (443) and the notification receivers.

Teleport - [ ] Protect the application with a role requiring require_session_mfa. - [ ] Join tokens: minimal TTL, client_code label, automatic revocation (tctl tokens rm) after installation: Tenantry identity needs delete on token; watch the audit for token.revoke with outcome=error and delete those tokens by hand (tctl tokens ls, then tctl tokens rm <token>). - [ ] Watch the Teleport audit for unexpected windowsdesktop joins.

Container and host - [ ] Regular image updates, npm audit --omit=dev in CI, resource limits, container logs with rotation and restricted access. - [ ] Restrict SSH/Docker access to the host: the docker group is equivalent to root and gives access to .env and the identity.

Backup and recovery - [ ] Back up .env and the compose files (encrypted), and the /data volume (audit log, client register, consoles, alerts) together with .env: the console tokens and channel secrets in /data are only readable with SECRETS_ENCRYPTION_KEY. - [ ] Compromise procedure: stop the container, revoke the tctl identity and all tokens (including the consoles fleet tokens and the alert webhooks), regenerate secrets, review the Teleport roles and applications created (tenantry-managed=true), check the GPOs created (rollback-ad.ps1).

8. Teleport authentication (SSO) and audit log

8.1 Teleport SSO login (JWT)

Teleport Application Access signs a JWT in the Teleport-Jwt-Assertion header of every request it forwards to the application. When TELEPORT_JWT_ENABLED=true, the authentication guard verifies this header on every request (stateless: no session to steal or fixate):

Variable Role
TELEPORT_JWT_ENABLED Enables JWT login (default false).
TELEPORT_JWT_ISSUER Cluster name (iss). Required if enabled.
TELEPORT_JWT_AUDIENCE URI of the application registered in Teleport (aud). Required if enabled.
TELEPORT_JWT_JWKS_URL URL of the proxy's JWKS (https://<proxy>/.well-known/jwks.json, https:// required in production). Required if enabled.
TELEPORT_JWT_ALLOWED_ROLES Allowed Teleport roles (comma-separated list).
TELEPORT_JWT_ALLOWED_USERS Teleport users allowed regardless of their role (break-glass access, optional). At least one of the two lists is required.
TELEPORT_JWT_ADMIN_ROLES MSP administrators: every client, global settings and cluster-wide actions (apply, every join token, roles, console endpoints). Explicit list only: empty (default) = no Teleport role is an administrator; it never falls back to TELEPORT_JWT_ALLOWED_ROLES. Break-glass users (TELEPORT_JWT_ALLOWED_USERS) and the local password login stay administrators. Every other allowed role is a scoped technician (section 12).
TELEPORT_JWT_CLIENT_ROLES Honour the roles tenantry-client-<code>: such a role alone signs in (as a technician, never an administrator) and adds that client to the scope. Default true.
TELEPORT_JWT_CLIENT_ROLE_LEVEL Level granted by a tenantry-client-<code> role: viewer (default) or operator.
TELEPORT_JWT_MAX_AGE_SECONDS Maximum age of a JWT (default 43200). exp and iat are required.
TELEPORT_ONLY Refuses password login (403). Requires TELEPORT_JWT_ENABLED=true (otherwise startup fails).

Behavior: - Accepted algorithms: RS256 and ES256 only (none and HMAC refused); iss, aud, exp/nbf with 30 s tolerance, roles claim read strictly. The key is fetched only from the configured address (cache and retry delay). - Header absent (mixed mode: local session cookie accepted) or invalid: 401. Valid header but role not allowed: 403. A header that is present but invalid is refused even if a valid session cookie accompanies the request. With TELEPORT_ONLY, cookies are ignored. - The identity is logged as teleport:<user>; CSRF is still required for state-changing requests. - GET /api/auth/config (public) returns { passwordLogin, teleport } so that the interface displays the right login screen. - Security condition: expose Tenantry only to Teleport (local listener or dedicated network). Without this, a direct client cannot forge the header (the signature is verified), but password mode would remain reachable: use TELEPORT_ONLY=true to close it.

8.2 Audit log

Append-only JSON Lines file (AUDIT_FILE, default /data/audit.jsonl in production — mount a volume; disabled outside production if the variable is absent), created at 0600. Each entry carries seq, ts, actor, ip, action, target, outcome (ok/denied/error), detail, prev and hash: the SHA-256 hash of the previous entry is included, so modifying, deleting or reordering a line is detected.

Events: auth.login (success/failure, the typed name is never recorded), auth.lockout, auth.logout, auth.logout-all, deployment.generate (join token creation), script.download (key masked), callback.ad-output (source IP in ip), callback.ad-output.fetch, adoutput.manual, steward.apply (including overwrite), bundle.download, token.create, token.revoke (target windowsdesktop:<client_code> or app-agent, detail reason=desktops-verified|finished|expired|proxy-verified result=revoked|absent|tctl tokens rm failed; actor system for the sweeper). Never a password, token, hash or bundle content. Entries about one client carry its code in an optional client field (included in the HMAC when present, so older entries still verify): a scoped technician reads only those of his clients and his own actions (section 12); GET /api/audit/verify is for administrators.

Consultation (authentication required): GET /api/audit?limit=100 (most recent first, 1000 at most; read from the end of the file, bounded memory) and GET /api/audit/verify (recomputes the chain as a stream, from the oldest segment to the most recent, returns { ok, entries, brokenAt?, file? } and never throws).

Keyed chain (HMAC). Chaining uses HMAC-SHA256 with a key derived from SESSION_SECRET (HKDF, label tenantry-audit): writing to the file is no longer enough to forge a valid chain. Consequence: changing SESSION_SECRET makes the old entries invalid for the new key; at startup the file is then quarantined (see below) and a new chain starts.

Rotation. Beyond AUDIT_MAX_BYTES (default 50 MiB) the current file becomes audit.<n>.jsonl and the next one continues the chain (seq and prev carried over). verify follows the segments in order: archive and keep all the audit.<n>.jsonl files together (deleting one makes verification fail at the junction).

Damaged chain. An unreadable, truncated or altered line is never extended: at startup, the file is renamed audit.broken-<timestamp>.jsonl, a new segment starts with an audit.chain_broken entry (target = name of the quarantined file) and the application starts normally. This restart entry is accepted by verify only as the first line of a file.

Volume. Script download denials are logged only for an existing deployment, and sampled (the first 5, then 1 in 25): a flood of anonymous requests does not make the log grow.

8.3 Node management (locking)

GET /api/nodes, GET /api/nodes/:id, GET /api/nodes/inventory (CSV export, audited nodes.export), POST /api/nodes/:id/lock and /unlock expose the fleet of client proxies. There is deliberately no management of users, roles or accounts.

Threat. A Teleport lock (tctl lock --server-id) immediately cuts a node's connections: this is a service disruption, and the lock right of the Tenantry role technically allows locking any user or node, administrators included. Locking a node running Auth or Proxy would cut the whole cluster.

Measures. - lock/unlock: session + CSRF + the operator level on the node's client (administrators: any node); a read-only technician receives 403 insufficient_level, a node of another client (or without client) 404 like an unknown id, before any tctl call (auth.forbidden logged). Section 12. - Target = server-id validated by schema and UUID expression (400 otherwise); never a user or a role. Nodes running Auth or Proxy are refused (409 protected_node, attempt logged as denied); Tenantry does not lock itself. - tctl via argv only; message limited to 200 characters, control characters replaced, duration allow-listed (1, 4, 12, 24, 72, 168 h; 24 h by default, null = no explicit expiry). - Reads fail closed: any tctl error (other than "not found") returns a generic 502, never an empty list; no tctl output is returned to the client. - Audit node.lock / node.unlock (target = host name and identifier, never a secret). Teleport's auth cache lags by a few seconds: the API waits at most 5 s and returns pending: true if the state is not yet visible.

Tenantry role rule. renderTenantryRole(cfg, name, allowRoleUpdate, allowNodeLock): by default (false) the role has no rights on locks. With allowNodeLock, it adds lock [list, read, create, update, delete] (create also requires update: the lock is an upsert, verified on Teleport 18.2) and, read-only, instance, windows_desktop_service, windows_desktop [list, read], with a warning comment. It also adds windows_desktop_labels: {'*':'*'} without windows_desktop_logins: Teleport filters desktop lists by this selector; this gives visibility (count per proxy), not RDP access. Enable only if operations want locking from Tenantry, with a short-lived credential.

8.4 Deploying the console through a client proxy (join token)

POST /api/bundles/proxy-token creates a tctl tokens add --type=app join token for a new client proxy that will publish the console. There is still no user or role management in Tenantry.

Threat. A valid token allows enrolling an agent in the cluster and publishing an application there. It must therefore be rare, short-lived and never leak (bundle, logs, audit, cache, history).

Measures. - Right: creating tokens is a right of the Tenantry role (token: list, read, create, delete; delete for the automatic revocation, list, read for the cap), absent by default from renderTenantryRole and added only with the allowProxyToken option (warning in the YAML); to be granted to a short-lived identity. API side: session + CSRF + requireAdmin (403 for a non-admin role, auth.forbidden logged); tctl is not called. - Fixed lifetime of 30 minutes, not adjustable by the client (the request body is ignored); type fixed to app; tctl arguments in argv, values validated. - Never logged: Cache-Control: no-store response, token absent from the application logs and the audit (token.create, target app-agent, detail ttl=30m); on failure, a generic 502 without tctl output (the detail, scrubbed of any token-like pattern, stays in the server log). The interface must not store it (in-memory state only). - Never in a bundle: install.sh reads it from a file (--token-file, recommended), --token (discouraged: visible in the process list) or a masked prompt; it validates its format, writes it to secrets/join/join-token (0600) and deletes it as soon as the agent's join is confirmed; without confirmation it keeps it and says so. It also asks you to delete the operator's copy. - Join by file: join_params.token_name points to /run/secrets/join-token (folder mounted read-only). After the first join, the agent restarts without a token: its credentials are in its data directory (agent-data volume, to be protected and backed up encrypted). - Agent container: cap_drop: ALL + DAC_OVERRIDE only (reading the token file as root), no-new-privileges, no published port; Docker socket never mounted. insecure_skip_verify: true exists only on the agent → console hop within the same compose; for an existing proxy, verification is on by default. - Existing proxy mode: enable-app-on-proxy.sh never automatically merges an existing app_service:, validates with teleport configure --test and restores the backup on failure; the console is reachable only from the proxy host (local binding or firewall). - Verification: POST /api/steward/verify indicates which agent serves the application; with expectServedBy any other agent is an error. The list of served applications is read fail-closed. - Revocation: the creation answer carries an opaque tokenId (random UUID, not derived from the token). As soon as the verification shows an application service serving the console, the page calls POST /api/bundles/proxy-token/revoke {tokenId} (admin, CSRF, 10/min): Tenantry runs tctl tokens rm and audits token.revoke. The token value is kept in Tenantry's memory only for that purpose, until revocation or expiry (404 afterwards). On failure (502) the page shows the manual tctl tokens rm command.

8.5 Review 3 (via client proxy)

Id Severity Finding Fix
REV3-01 Medium With expectServedBy, a malicious agent serving the same application name next to the real proxy kept the verification green. All serving agents must be the expected host (otherwise failure, extra servers listed); exact (case-insensitive) comparison, no more short name / first label of an FQDN matching.
REV3-02 Medium enable-app-on-proxy.sh (root) modified /etc/teleport.yaml in place. Write to a temporary file in the same folder (owner and permissions copied), teleport configure --test validation on that file, then mv; trap EXIT/INT/TERM restores the backup; systemctl is-active check after restart (otherwise restore and restart, non-zero code); confirmation before restart unless --yes; flock; merge refused for any form of the app_service key (flow, indented, quoted).
REV3-03 Medium The audience https://steward:8443 and the application name steward were identical on all sites: a JWT from one site was valid on another, two sites registered the same application. Audience, network alias and application name per instance (steward-<name>), for both modes; the default public address derives from it.
REV3-04 Info An application token allows registering any application name / public_addr for 30 min; the token: create right cannot be limited to the app type (a stolen credential could create node or proxy tokens). Documented (also in Tenantry role comment): create the token just before use, short-lived credential, "served by" check.
REV3-05 Medium Unlimited token creation. 3 requests/min, serialized creation, 409 refusal if ≥ 5 valid application tokens (tctl tokens ls), fail closed if the list is unreadable.
REV3-06 Medium The host archive contained secrets/join; agent restore with -C /. --exclude=secrets/join, extraction into /var/lib/teleport only, warning: restore the agent identity on the same host (a clone shares the host identifier).
REV3-07 Low Join detection re-read old logs. --since the start time; 25 last lines at most; offer to delete (shred) the token file.
REV3-08 Low Agent and console on the default network (and helpdesk network). Dedicated agent-link network shared by the agent and the console only (not internal: the agent connects out to the master). DAC_OVERRIDE kept: the agent runs as root in the container and reads a 0600 file owned by the host user.
REV3-10 Low Unprotected countdown, token remaining "seen" after expiry. Unreadable date = expired; on expiry the token is erased, the step requires a new token, and the clipboard is cleared on a best-effort basis (read permission required). The token lives only in the value of a password field.
REV3-11 Low verify returned the serving host names to any logged-in user. servedBy and the host names in the checks: administrators only.

Existing client proxy: re-enrollment (proven in the lab, Teleport 18.2). An agent's service rights come from the identity issued when it joins; adding app_service to an already-joined proxy fails even with a valid token. POST /api/bundles/proxy-token therefore accepts { roles: 'app+windowsdesktop' } (allow-list app | app+windowsdesktop, 400 otherwise): a two-role token, 30 minutes, same limits (3/min, cap of 5 live application tokens, all types) and same audit (the role set is logged, never the token). A two-role token is more powerful (it also enrolls a Windows desktop service): create it just before use. enable-app-on-proxy.sh --token-file --both-roles sets aside the old identity (<data_dir>/proc), installs configuration and token (atomic, 0600, never displayed), restarts, requires the join log marker with no error, restores identity, configuration and token at the slightest failure or signal, then deletes (shred) the token and the old identity. Effects: the proxy takes a new host identifier (locks placed by identifier, server-id, do not follow; the old instance expires) and its sessions are cut for a few seconds; confirmation mandatory (machine name or --yes). A plaintext token in the configuration is refused.

Review 4 (re-enrollment). A two-role token lives 10 minutes (ttlMinutes in the response, 30 for the application only) and stays multi-use until deleted (tctl tokens rm): the script destroys the local copies (shred) and reminds you to delete it in Teleport; Tenantry itself revokes it once the verification shows the proxy serving the console (see 8.4, Revocation). Uninterruptible restore (signals ignored, restore log), flags set before the actions and actual existence of the backups tested, partial failure reported loudly, 15 s observation before deleting the old identity, immediate failure on a join refusal, configuration snapshot checked before installation, aborted identity destroyed (private keys), ambiguous data_dir or missing proc: failure before any modification. The token cap counts only application tokens with a readable future expiry (a static token does not lock the route); the creation lock is per process, the cap read from the cluster protects across instances.

9. Adversarial review

Independent review of the authentication, SSO and audit code. All findings were confirmed in the code and then fixed, each with a test (test/review-routes.test.ts, test/review-audit.test.ts).

Id Severity Finding Fix Status
REV-01 Medium Login lockout was keyed on the name alone: 5 anonymous attempts on admin locked out the administrator. Key = (client IP, normalized name); the attacker locks only themselves out; 5/min/IP limit and exponential delay kept; constant 1 s delay (never a lock) when ≥ 20 global failures in 10 min. fixed
REV-02 Medium Anonymous requests made the log grow without bound; tail/verify read the whole file. Download denials logged only for an existing deployment and sampled; AUDIT_MAX_BYTES with chained rotation; tail reads from the end (bounded buffer); verify reads as a stream. fixed
REV-03 Medium A truncated or altered line restarted the chain from zero in the same file, verify/tail threw an error. Line-by-line reading; verify returns brokenAt; damaged file quarantined (audit.broken-<ts>.jsonl) and audit.chain_broken entry in a new segment; startup guaranteed. fixed
REV-04 Low Eviction from the lock table could delete the entry of a locked account during a name spraying attack. Never evict a locked entry; eviction by oldest lastFailMs; hard cap. fixed
REV-05 Low NODE_ENV not checked: prod or Production enabled development mode. Only production, development, test accepted, otherwise startup error. fixed
REV-06 Medium HEAD on the install.sh URL consumed the single-use token. Automatic HEAD route disabled, HEAD answers 405 with no effect; install.sh is marked served only after successful generation, just before sending (no intermediate await). fixed
REV-07 Medium The Teleport JWT required neither exp nor iat nor a maximum age. requiredClaims: ['exp','iat'], maxTokenAge = TELEPORT_JWT_MAX_AGE_SECONDS (default 43200). fixed
REV-08 Medium no-store header, secret path masking and SPA fallback used the raw URL: /%61pi/... bypassed them. Matching route pattern, otherwise decoded, normalized path (duplicate slashes, case); masking applies to the decoded path. fixed
REV-09 Info No role differentiation after login. TELEPORT_JWT_ADMIN_ROLES (explicit list since review 4, no default): POST /api/steward/apply and POST /api/deployment/generate return 403 (auth.forbidden logged) without an administrator role. Break-glass users (TELEPORT_JWT_ALLOWED_USERS) and the password session are always administrators. fixed
REV-10 Info Audit chain without a key; no alert if TRUST_PROXY is absent behind Teleport. HMAC-SHA256, HKDF key derived from SESSION_SECRET; startup warning if TELEPORT_JWT_ENABLED=true without TRUST_PROXY (all clients would share a single rate-limit bucket). fixed

Operations note: without TRUST_PROXY, behind Teleport all clients have the same IP; lockout and the 5/min limit are then shared (the attacker can hinder the administrator). Set TRUST_PROXY to the number of hops or the proxy's CIDR.

10. Review 2 (bundles and nodes)

Second independent review of the deployment scripts and the nodes API. Findings confirmed and then fixed, each with a test (packages/backend/test/nodes.test.ts, packages/shared/test/review2.test.ts, ProxiesPage.test.tsx).

Id Severity Finding Fix Status
REV2-01 High The "until unlocked" lock silently expired after 24 h (the UI omitted ttlHours). The UI always sends ttlHours (null = no expiry); the response returns expiresAt (or null) and the UI displays it. fixed
REV2-02 Medium restore.sh: command injection / path traversal via .db-path. Allow-list ^data/backups/[A-Za-z0-9._-]+\.db$, value passed through -e DBPATH, confirmation (project name) unless --yes. fixed
REV2-03 Medium apply-teleport.sh: check_free failed open. Only an explicit "not found" means "absent"; any other error stops; ownership read from metadata.labels in the JSON. fixed
REV2-04 Medium nginx/Traefik/Caddy examples without TLS verification even off loopback. Unverified form only for a loopback bind; otherwise verification against proxy/console-cert.pem + warning. fixed
REV2-05 Medium listStrict read an authentication error as an empty list. "Not found" accepted only if it names the resource type and is not an auth/network error; otherwise fail closed. fixed
REV2-06 Medium Unlock also deleted other people's locks. Tenantry locks marked [tenantry]; only those are removed; otherwise 409 foreign_lock; names and messages audited. fixed
REV2-07 Medium Lockable types unbounded, instance duplicates. Allow-list gateway/app/ssh with services; check of all entries with the same id (409 protected_node). fixed
REV2-08 Medium Audit after the wait, no cache, concurrent locks. Audit as soon as tctl succeeds; 5 s cache with deduplication of in-flight requests; per-node mutex. fixed
REV2-09 Low Host names validated after trim. Validation of the raw string; trim only at the API boundary. fixed
REV2-10 Low Silent load_image, floating alpine, non-root backup of data/ (0700), umask, deletion without a code, paths with :. Pull error reported and image verified; alpine:3.22.1; host archive created by a read-only root container; umask 077 and 0700 folder; random code (and ACME volume mentioned) for --purge; safe_path. The install token is never written to a file. fixed

11. MSP control plane (clients, consoles, alerts)

Client register, Steward consoles polling and cross-client alerts (see Clients and consoles and Alerts). This adds Tenantry's first outbound connections (F5, F6) and secrets at rest in /data.

Access. Reads (GET /api/clients*, /api/consoles, /api/alerts) are filtered by the caller's client scope; alert settings and channels are for administrators (read included). Writes: CSRF, then requireAdmin (create / delete a client or a console, change a console's URL or CA, alert settings and channels) or the operator level on the client (edit a client, edit or poll a console, replace its token), body validated by schema then by the shared validators, audited (client.*, console.*, alerts.*). /api/auth/me returns admin and scope as display hints only; every route enforces them (section 12).

Secrets at rest. Console tokens, channel URLs (Teams/Slack/webhook URLs embed their key), webhook HMAC secrets and ntfy tokens are encrypted with AES-256-GCM: key = HKDF-SHA256(SECRETS_ENCRYPTION_KEY, label tenantry-secrets-v1), random 96-bit IV per value, associated data console:<id>:token / channel:<id>:url|secret (a ciphertext moved to another record or field fails authentication). The key is a dedicated secret (≥ 32 characters, refused if equal to SESSION_SECRET), generated by scripts/install.sh when absent and kept across re-runs (--reset-secrets replaces it, with a warning). Production without it: writes needing a secret answer 503 encryption_unavailable (fail closed, never stored in clear). Outside production: a throwaway key per process.

Write-only. No API response contains a token, a channel URL or a secret (only hasToken, hasSecret, the URL origin and the CA fingerprint); the audit records token=replaced, secret=set|cleared, never values; pino redacts *.secret, *.tokenEnc, *.urlEnc, *.secretEnc, *.secretsKey and the authorization header. The UI uses password fields, never prefilled, cleared after submit and never kept in storage. Plaintexts exist only in memory, for one outbound request.

Token bound to its endpoint. A console's URL and pinned CA decide where Tenantry sends its bearer token, so changing either is an administrator action (403 admin_required for operators, audited as auth.forbidden, nothing written), and any URL or CA change that does not come with a new token drops the stored one (token=dropped in the audit): the console then shows token_missing and is not polled at all until a token is entered for the new endpoint. A poll still in flight with the old settings is discarded.

What technicians see of a TLS handshake. For scoped technicians, the certificate read by a poll (or by the probe after a TLS failure) is reduced to its validity dates and whether it was trusted: subject, SANs, issuer, fingerprint and the TLS error code describe whichever host answered and are for administrators, as is the issuer in certificate_expiring alerts.

SSRF considerations. Targets are chosen by an administrator, never taken from a request parameter, and Tenantry is already a privileged component. Private ranges are deliberately not blocked: consoles are private (client networks reached from the Tenantry host), and a block list would push operators to turn it off. The controls are instead:

Control Effect
https: only (URL parser, no credentials; console URL without query or fragment) No http, file, gopher… schemes; no token in clear.
TLS verification always on; the optional per-console CA replaces the system store A spoofed internal name cannot present a valid certificate; a pinned CA limits trust to the client's PKI.
Redirects never followed (3xx = redirect_refused) A console or receiver cannot bounce the request (and its bearer token) to another host.
10 s total timeout, 256 KiB response cap (socket destroyed when crossed), no keep-alive agent No slow-read or memory exhaustion; no connection reused across targets.
Status document validated strictly (exact keys, enums, bounded strings and arrays) and rebuilt field by field No unexpected data reaches the UI or the alert engine.
instance.clientCode must match the console's code A URL pointing at another client's console is flagged (client_mismatch).
Errors are stable codes; response bodies are never returned, logged or stored The poller cannot be used to read internal pages (no oracle beyond an error code and an HTTP status).
Poll now / Send test: admin, CSRF, 10 per minute; polling concurrency 4 No scanning amplification through the API.

Residual: an administrator can make Tenantry open a TLS connection (then one GET or POST) to any HTTPS host it can reach and learn whether it answered (error code, status). Accepted: administrators already control Tenantry. Restrict egress at the network level (operations checklist).

Integrity and availability of the state. Atomic writes (temporary 0600 file, fsync, rename), serialised updates, schema version; an unreadable file is quarantined (.broken-<ts>), never overwritten. The alert state is persisted, so a restart neither re-sends nor loses active alerts; a rule never resolves on missing data (unreadable fleet, failed poll).

Notifications carry the alert kind, client code, subject (host name, console host, DC FQDN) and counters: no secret and no personal data from the register. Webhook receivers should verify X-Tenantry-Signature (HMAC-SHA256 over <timestamp>.<body>) and reject timestamps older than 5 minutes.

Personal data. Client contacts (names, e-mail addresses, phone numbers) are stored in clear in /data/clients.json (0600, volume) and readable by every signed-in user: keep only what operations need; deleting a client erases its contacts.

Tests. packages/backend/test/controlplane.test.ts: AES-GCM round trip and AAD binding, atomic store and quarantine, strict schema, poller against a fake HTTPS console (pinned CA accepted, unrelated CA and system store refused, redirect, oversize, extra key, wrong client, 401/500, timeout), write-only token (absent from responses, state file and audit), alert deduplication across a restart, webhook HMAC, ntfy / Slack / Teams formats.

12. Per-client technician scoping

Model and operations: 13 — Technicians and client scopes. Two levels: MSP administrators (every client) and scoped technicians, whose clients come from assignments in Tenantry (/data/technicians.json) and from the Teleport roles tenantry-client-<code> in their JWT, intersected with the client register, at a viewer or operator level per client. The local password login is administrator break-glass only.

Threats. A technician of client A reading client B's fleet, desktops, consoles, alerts or audit (information disclosure across tenants); acting on B (locking B's proxy, creating a join token for B, editing B's console URL so Tenantry sends B's bearer token elsewhere); enumerating B's resources through ids, exports, filters or error differences; keeping access after an assignment is removed.

Controls.

Control Effect
One central guard (auth/access.ts): req.access resolved with the identity on every request, fail closed No route decides scoping on its own; an unreadable state gives no client; a change applies to the next request (stateless JWT, no session to revoke).
Reads filtered server side (nodes and summary, inventory export, clients, consoles, alerts and history, audit, gateways) The UI receives only in-scope data; counts on the dashboard are computed on the filtered data.
Out-of-scope id = 404 identical to an unknown id (nodes, deep links, consoles, deployments, client codes) No existence oracle across clients.
Actions: operator level on the resource's client, checked before any side effect; client named in the body checked (403 client_scope); undetermined client refused A refused lock never reaches tctl; a console cannot be moved to another client nor pointed at another host.
Unregistered client codes and nodes without a client: administrators only A rogue agent labelled with an unknown code, or a role tenantry-client-<unregistered>, opens nothing.
Cluster-wide routes (Steward integration, cluster bundle, every join token (new proxy, Windows deployment), creating roles, console URL / CA, alert channels, Technicians, audit verification): administrators only, checked in onRequest (before the body is parsed or validated) Technicians cannot change what all clients share; a join token, valid cluster-wide whatever its labels, is never in a technician's hands.
Every refusal audited (auth.forbidden); audit entries filed under their client inside the HMAC Attempts are visible to administrators; a technician's audit view cannot be widened by editing the file without breaking the chain.
Convention role parsed strictly (tenantry-client- + valid lowercase code) tenantry-client-ACME, x-tenantry-client-acme or an empty code grant nothing.
Route classification test (scoping.test.ts): every /api route must be listed as public, any, admin or scoped; every admin route must run requireAdmin in onRequest and answer 403 forbidden to technicians with a body that is valid against the route's own schema (and with an invalid one); every scoped route refuses or hides everything from a technician without clients A new route cannot ship without a scoping decision, and a 400 can never pass for a refusal.

Teleport side. The generated role tenantry-client-<code> grants only windows_desktop_labels: {client_code: <code>} with the user's {{internal.windows_logins}}: the same convention scopes RDP. It is created by an administrator only, and never replaces an unmanaged role of the same name without an explicit overwrite.

Residual risks.

  • Client scoping cannot constrain a join token. The labels of a Teleport join token are labels of the token: they do not bind the labels the joining agent advertises, so a windowsdesktop token is in effect valid for the whole cluster (an agent joining with it can claim any client_code). Every token-creating route is therefore administrator only (POST /api/deployment/generate, POST /api/bundles/proxy-token); technicians keep the guided follow-up of a deployment an administrator generated. The Tenantry bot holds no token right unless installed with --bot-role full.
  • TELEPORT_JWT_ADMIN_ROLES is explicit: while it is empty, only break-glass users and the local login administer Tenantry (startup warning). Set it to your administrators' role.
  • Scope comes from Teleport roles and Tenantry assignments: whoever administers Teleport roles or SSO group mappings controls technician scopes. Protect those like administrator rights.
  • nodes.export and /api/audit for technicians are bounded but still a bulk read of their own clients' data: expected.

13. Review 4 (October 2026)

Audit of the API scoping, the same-server bootstrap, the release pipeline and the generated scripts. Every row is fixed in this branch; tests named in the last column.

Id Severity Finding Fix Tests
A1 Medium PUT /api/consoles/:id let an operator change url / caPem while the stored token was kept: token exfiltration to any host, and SSRF / port-scan / TLS reconnaissance through the poller. URL and CA changes are administrator only (403 admin_required, audited, nothing written); any URL or CA change without a new token drops the stored token (token=dropped, console token_missing, not polled). Technicians get certificates redacted to the dates and the trust flag, and certificate alerts without the issuer (section 11). scoping.test.ts (consoles), ConsolesPanel.test.tsx
A2 Medium POST /api/deployment/generate (operator level) returned a windowsdesktop join token valid for the whole cluster: the token's labels do not bind the agent. Every token-creating route is administrator only; documented that client scoping cannot constrain join tokens (section 12, Technicians). Add a proxy is shown to administrators only. scoping.test.ts (deployments), technicians/scoping.test.tsx
A3 Low The generic "admin routes refuse technicians" test accepted 400, so a schema error could hide a missing role check. requireAdmin runs in onRequest on every admin route (before parsing and validation). The test asserts 403 forbidden with bodies validated against each route's own schema, the onRequest placement, 403 for an invalid body too, and a generic test for every scoped route. scoping.test.ts (every route)
A4 Low TELEPORT_JWT_ADMIN_ROLES defaulted to the allowed roles: every allowed role was an administrator until it was set. Explicit list only, empty = no SSO administrator (break-glass users and the local login stay); startup warning; install.sh asks for it (--sso-admin-roles). teleportAuth.test.ts, install-wizard.test.ts
A5 Low TRUST_PROXY=1 trusted X-Forwarded-For from any peer one hop away. Caddy / cloudflared run at a fixed address on a dedicated edge network shared only with Tenantry, and TRUST_PROXY is that single address; --expose proxy needs --trust-proxy ADDRESS (otherwise the header is ignored, with a warning and the Docker gateway address printed). A hop count still works but warns at startup. install-tls.test.ts, install-wizard.test.ts
B1 Medium-High The same-server bootstrap created the Teleport role from text printed by the (unpinned) image. Role texts embedded in install.sh (one YAML document each, checked by a test against the shared generators); the image's copy is only compared, any difference aborts before Teleport is touched. --version mandatory for published images (no implicit latest), image pinned by digest, cosign verification against the release workflow identity of the tag when cosign is present, --require-signature to make it mandatory; --build kept for local builds. install-roles.test.ts, install-machine-id.test.ts
B2 Medium The bot role was close to cluster admin, full by default. Minimal by default (app registration and fleet listings, no role, token or lock right); the installer creates steward-admin / steward-helpdesk with the local tctl; --bot-role full is opt-in with a warning. Steward apply keeps existing managed roles instead of rewriting them. install-roles.test.ts, steward.test.ts
B3 Medium scripts/deploy-master.sh ran Tenantry as root, unsandboxed, on 0.0.0.0, with a world-readable .env. Removed in favour of install.sh --teleport-local (non-root read-only container, 0600 .env, Machine ID). —
B4 Medium The release built tctl from a mutable tag. ARG TELEPORT_COMMIT hard-codes the commit of v18.11.1; the build fails when it is empty, malformed or not the checked-out HEAD. image build on the lab
B5 Medium The release workflow could publish from any ref. Publishing job in the protected release environment, runs only for v* tags (manual runs from a tag only), permissions: {} by default. Tag and branch rulesets documented in CONTRIBUTING.md. actionlint
B6 Low Actions and base images referenced by mutable tags. Every action pinned by commit SHA (version in a comment), base images by digest, tbot image resolved and pinned by digest at install time; Dependabot for actions, npm and Docker. actionlint
B7 Low Role files were copied into a bind mount of the Teleport container. Sent on stdin: docker exec -i … tctl create -f -. install-machine-id.test.ts
B8 Low ACME EAB HMAC key on a command line; DNS secrets visible in docker inspect. secrets/acme-dns.env mounted read-only and read inside the acme.sh container; the HMAC key reaches acme.sh through its ca.conf written by a shell builtin. install-tls.test.ts, acme.test.ts
B9 Low The downloaded PowerShell script (it embeds a callback token) stayed in %TEMP%; verify-from-teleport.sh used fixed /tmp names. Unpredictable file name, removed in finally; private mktemp -d directory removed on exit. script-cleanup.test.ts
B10 Low Older vitest major in backend / shared; dependency install scripts ran in CI. vitest 5 everywhere (dev only); npm ci --ignore-scripts then npm rebuild argon2 esbuild in CI and the image. CI
B11 Info No third-party notices for tctl's Go dependencies. Pinned go-licenses in the tctl build stage; the notices are part of THIRD-PARTY-NOTICES.txt (About panel) and /licenses/tctl-go-dependencies/. image build on the lab

Also fixed: three install-wizard.test.ts tests failed on main (expected tbot.yaml without ca_pins). The test was right: the backend tests imported a stale build of @tenantry/shared; they now resolve its source directly.