Security model¶
This page is written for client security officers and MSP security leads who must approve a Steward instance. It describes what protects the directory, what the console never does, and the risks that remain. In short:
- the console holds no administrative right: two non-privileged service accounts, with AD rights a domain administrator grants per OU and per attribute;
- every action needs two authorizations, the console's delegation engine and AD's own ACLs;
- Tier 0/1 accounts and groups are never touched, whatever the role;
- everything is audited in a tamper-evident chain that can be forwarded to a SIEM;
- the console is never published raw on the Internet.
Deployment and network exposure¶
- One instance per client, on a Linux member server (never a domain controller), in containers that run as a non-root user
with a read-only root filesystem, no capabilities and
no-new-privileges. Instances share nothing: directory, volumes, database, secrets and audit are per client. - HTTPS only, with a certificate browsers trust (ACME, renewed automatically, or your own): see HTTPS certificate. Plain HTTP only redirects (and answers ACME HTTP-01 challenges). HSTS is sent.
- Only LDAPS to the DCs, with strict certificate validation (no "ignore errors" option; plain LDAP is refused).
- Exposure: reach the console from the client's LAN, through Teleport (recommended for MSP technicians: MFA, no inbound port, sessions bound to Teleport's signed identity), behind your reverse proxy or WAF, or through Cloudflare Tunnel with Cloudflare Access (no port published at all). Never publish it directly on the Internet: AD password sign-in has no MFA of its own. See Exposure.
- Forwarded headers are trusted from configured proxies only.
Network:KnownProxies/Network:KnownNetworksname the reverse proxies (install.sh --trusted-proxy; with Cloudflare Tunnel, cloudflared alone on its own fixed-subnet network, set automatically). From those peers only, the client address is taken fromX-Forwarded-For(orCF-Connecting-IP, set by Cloudflare's edge); from anyone else the header is ignored, so a client cannot pick its own throttle bucket. The sign-in throttle, the rate limits and the fleet failure budget then key on the real client, and only failed sign-ins spend the per-IP budget. Nothing is trusted by default (a proxy left unconfigured means one budget for everyone behind it). Identity checked in front (Access, Teleport) is still what keeps unknown people away from the sign-in page. - Outbound URLs are not logged: notification webhooks (Teams, Slack, the webhook URL itself can be the secret), the Teleport
JWKS and the password-service link run on HTTP clients whose request logging is off, and
System.Net.Httplogs warnings only. - Secrets on the host (helpdesk and LAPS passwords, ACME DNS token, tunnel token) live in
<instance>/secrets/, readable only by the container that needs each of them (modes 0400/0600), mounted as Docker secrets: never in compose files,.env, the environment of a container, or the installer's output. The guided install reads them without echo. - Supply chain: release images are signed with cosign (keyless, GitHub OIDC) and carry an SBOM and provenance. The installer
resolves the image to its digest, writes
image@sha256:...into.env(restarts and recreates run exactly what was verified) and runscosign verifyagainst the release workflow's identity when cosign is installed (a warning otherwise;--require-signaturemakes it mandatory): see Installer reference. Third-party sidecars (acme.sh, cloudflared) and the backup helper (alpine) are pinned by digest; every GitHub Action of the build is pinned by commit SHA and Dependabot proposes the updates (actions, npm, NuGet, base images).
Two independent authorizations¶
- The console decides. A request is allowed only when a single delegation covers the action, the attribute and the
target's OU at once. The default is deny, and any error also means deny. Privileged and unclassifiable targets are always
denied. VIP targets need an explicit
vip.managedelegation, which never applies to Tier 0/1 accounts. The facts about a target are read from AD byobjectGUIDat every sensitive operation; nothing from the browser is trusted. - AD decides. The console writes with a dedicated, non-privileged service account (and, with the helpdesk, resets passwords
with another one). Its ACEs cover only the (OU, attribute)
pairs the roles need, and are applied by a domain administrator with
Grant-StewardDelegation.ps1. If the two disagree, AD refuses and the console reports it. The diagnostics show any mismatch before anyone tries. The exported model is not trusted by the script (it travels as a file and can be tampered with): - The administrator types the managed OUs (
-ManagedOu, DNs or GUIDs) and every service account (-Identity @{ read=...; writeAttributes=...; passwordReset=...; lapsRead=... }); both must match the file exactly (OUs by objectGUID, accounts by SID, in both directions), and the resolved plan (OU DNs and GUIDs, account SIDs) is printed and confirmed (ConfirmImpact=High) before any ACE is added. It never proceeds silently;-WhatIfshows the plan only. - Privileged areas: a managed OU at, above or below the Domain Controllers OU, the System container / AdminSDHolder or an
operator-declared
-TierOuis refused, as is one whose subtree holdsadminCount=1objects, DC/RODC accounts, sensitive groups or their (nested) members. - DN confusion: subtree checks use AD's own objectGUID subtree search AND a full RFC 4514 RDN parser (escaped
\,,\+, hex escapes), never DN text comparison. - Identities: service accounts with a non-empty
sIDHistoryare refused. DnsAdmins (variable RID) and the Exchange groups are located by name across the domain, by the DNS containers' ACLs and by Exchange'sotherWellKnownObjects, then matched by SID, never at assumed DNs (the console's own classifier also searches them domain-wide bysAMAccountName).
What the console never does¶
- Use a Domain Admin account, or let a privileged account sign in (unless the host explicitly allows it).
- Change ACLs, or operate outside the managed OUs.
- Store or log a user's password. Service-account passwords are encrypted with AES-GCM under a master key kept in
/secrets. - Accept a DN, an attribute name or an identity header from the browser as proof of anything.
Accounts and sessions¶
- Sign-in: AD LDAPS bind with strict certificate validation, and/or Teleport single sign-on (signed JWT, see below).
Throttling stays below the domain lockout threshold. A failed sign-in gets a generic answer, padded to a constant time.
Second factor: an AD operator who has enrolled an authenticator signs in with the AD password AND a current code (asked only
after the password was proven:
401 totp_required; a wrong code401 totp_invalid, same per-operator throttle as step-up, replayed steps refused). A password reset by someone else is therefore not enough to sign in as an enrolled operator. No operator with rights signs in with a password alone (step-up modeauthenticator, the default): an AD operator who holds at least one role and has no authenticator yet ENROLS ONE INSIDE THE SIGN-IN. After the password is proven, the answer is401 totp_enrolment_requiredwith a new key (QR + text, shown once); the same sign-in sent again with a first code of it activates it, and only then is a session issued (auditedauth.totp.enrol/started:sign_in,enrolled:sign_in). The setup wizard's first-administrator step does the same before granting the administrator roles. Sessions carry a "second factor proven" mark: an AD session without it ends as soon as its operator holds a role (session.revoked, reasonsecond_factor_required): roles granted to someone already signed in, sessions from before the upgrade or from before a switch to this mode. Migration: after the upgrade every AD operator with a role is asked to enrol at their next sign-in (their current session ends at its next request); operators whose authenticator an administrator resets enrol again the same way. In modespasswordandnonethis enrolment is not forced (operators who enrolled still give their code); diagnostics then show a warning (signin_password_only) with the number of AD operators holding a role who sign in with the password alone. Residual risk: whoever knows the password of an operator who holds a role but has never enrolled can enrol first; enrol operators as soon as they get a role and reviewauth.totp.enrolrows withstarted:sign_in. - Sessions: stored server-side; the cookie is
Secure,HttpOnlyandSameSite=Strict. Sessions have an idle and an absolute timeout. The AD account is checked again every 15 minutes and loses access when disabled, deleted or made privileged. - Step-up (sensitive changes: powerful rights, weaker protections, resets, disable/enable, account creation, templates...):
- AD (Windows) sessions: a 6-digit code of an authenticator app the operator enrolled on the Security page (TOTP,
RFC 6238, SHA1, 30 s, ±1 step; secret AES-GCM encrypted with the master key, shown once; a step is never accepted twice;
5 wrong codes per 5 minutes per operator, then
429), valid 5 minutes, audited without the code. In this (default) mode a password is NEVER accepted for step-up. The AD password is typed once, to start the FIRST enrolment (bind as the session's own account, same budgets and audit as sign-in): that is the bootstrap of every AD operator, the first administrator included (the setup wizard itself has no step-up). Replacing an authenticator needs a current code; a lost one is reset by an administrator (delegations.manage+ own step-up, AND the administrator must dominate the operator: hold every console right they hold, a Super administrator's only by another Super administrator, never their own; refusals auditedoperators.totp_reset/denied,operator_not_dominated) or with the host commandreset-totp, both audited, then the password starts a new enrolment (inside the next sign-in for an operator who holds a role). This is the default step-up mode ("authenticator"); see "Step-up mode" below for the two weaker options. - Teleport sessions: nothing to type. The session is bound to Teleport's signed JWT on every request and Teleport
enforced its MFA at sign-in: the session itself satisfies step-up. Every sensitive change it makes is audited with a row
auth.reauth, reasonstepup:teleport, same correlation id as the change (written only when the change goes through). No console authenticator for them (refused,totp_ad_only).TrustSessionMfaandStepUpMaxAgeare obsolete and ignored (startup warning);Authentication:Teleport:StepUp: Denyforbids step-up-gated changes to Teleport operators. See Teleport sign-in. - No way up through
delegations.manage: creating or editing a role so that it holdssettings.manage, assigning, mapping (Teleport) or re-enabling a role that holds it needs an actor who already holdssettings.manage(settings_manage_required); granting, taking away or re-enabling the Super administrator role needs a Super administrator (super_administrator_required). Powerful grants (delegations administration, settings, Super administrator, VIP override...) ALWAYS need a fresh proof, even in step-up modenone(strict_reauth_required: an authenticator code when enrolled, else the AD password). - Self-protection: an administrator may change their own roles, but only after a step-up (always, even for a harmless
change), and the change is audited as
operators.self_roles_changed. Nobody can disable their own account, and the last delegation administrator cannot be removed (not even by themselves): not by a role, operator or Teleport mapping change, and a Teleport-side role loss of the last one keeps that role (auditedoperators.teleport_last_admin_kept). - Own AD account: nobody changes the group memberships of, resets, unlocks, disables or enables their own AD account. A
Teleport session has no AD account bound: the console resolves the JWT
usernameandwindows_loginstrait in AD and protects every account found; if the DC cannot answer, those actions are refused (fail closed). - Rate limits: per validated Teleport user behind Teleport (every request comes from the agent's IP), per IP otherwise.
- Deleting an operator needs
delegations.manageand a recent step-up; never yourself, never the last delegation administrator. It removes the console record (not the AD account); the audit trail is kept. A Teleport operator who keeps Teleport access is re-created at the next sign-in with the roles the Teleport mappings grant: disable instead to keep them out.
Console operators' own AD accounts (operator-takeover protection)¶
The AD account an operator signs in with is also a directory object: whoever may reset its password, disable, rename or move it, change its attributes or its groups could sign in as that operator and use their console rights.
- Server-side fact: for every write decision on an account, the console looks up whether its objectGUID is the subject of an AD operator (Operators table) and reads that operator's CURRENT grants. Unknown (database unreadable) refuses the write.
- Rule (engine, every path: single actions, bulk, leaver, approvals, member changes, moves): a write on an operator's account is
allowed only to an operator who already holds ALL of that operator's console rights: every application permission, and for each
of its delegations one of the actor's delegations with the same or more actions (none held only "with approval" when the target
holds it directly), attributes and OUs (same OU, sub-OUs when the target has them, no exclusion the target lacks; compared
structurally, so anything not evidently covered is refused). A Super administrator's account: only another Super administrator.
Never one's own account (
SelfAccount). Refusal reason:ConsoleOperatorAccount. Reading the account is not affected. - Recommended: put the AD accounts of console operators (at least administrators) in a VIP/protected group (Configuration ›
Protection): their writes then also need
vip.manage, and they stand out in every list. Enrol an authenticator for every AD operator: it is required at sign-in once enrolled. - Teleport operators: at every Teleport sign-in and revalidation the console resolves the AD account(s) behind the JWT
(
Authentication:Teleport:AdAccountClaims, defaultusername,trait:windows_logins: the username as a UPN or a sAMAccountName of the domain, and everywindows_loginsvalue) and records their objectGUIDs on the operator (operators.teleport_ad_accountswhen they change). Those accounts are then that operator's own for the same rule: only an operator who dominates them may change them, never they themselves. An account recorded while the DC could not answer is kept from the previous sign-in (never cleared on an error); a Teleport operator who has not signed in since the upgrade has none recorded yet. Set the option tononeonly if those accounts are protected otherwise (Tier OU, sensitive group). - AD groups behind Teleport mappings: a mapping on a trait whose value names an AD group (a DN or a SID, e.g. a
groupstrait your identity provider fills from AD) grants console roles to every member of that group. When the mapping is saved, such a value is resolved in AD (the save is refused,directory_unavailable, when the DC cannot answer) and the group joins the protected (sensitive) groups: nobody changes its membership through the console. Not determinable by the console, and to protect yourself: Teleport roles granted from AD groups by your Teleport SSO connector (attributes_to_roles/claims_to_roles), and trait values that are group NAMES rather than DNs or SIDs: add those AD groups to Configuration › Protection › sensitive groups.
Teleport single sign-on (no password typed into the console)¶
- Identity: only the JWT Teleport signs into
Teleport-Jwt-Assertion(signature from the cluster keys, issuer, audience, expiry). A request through Teleport signs the operator in by itself; a session is bound to that Teleport user and ends at the first request without a JWT for the same user (the network rule that only Teleport reaches the console stays required). - Role mapping: administrators map Teleport roles (and optionally traits) to console roles. At every Teleport sign-in and
revalidation the MAPPED roles follow the JWT (added and removed, audited
operators.teleport_roles_changed); roles assigned by hand are never touched. A malformedrolesortraitsclaim makes the whole token unusable. A mapping can only ever grant to Teleport operators, never to an AD operator. Editing mappings needsdelegations.manageand a step-up; a mapping onto a powerful role is audited again (teleport.mappings_powerful): whoever administers that Teleport role then holds those console rights, so protect the Teleport role like the console role. - "Teleport only": an instance setting (
settings.manage+ step-up, audited) that refuses AD password sign-in (and so every AD session and its authenticator step-up) and serves nothing without a Teleport JWT. It only restricts the host configuration, it can only be switched on from a Teleport session, and it ends every AD session. Recovery from the host:allow-password-login --confirm. - Teleport itself can delegate to an external identity provider (GitHub SSO in Community Edition; OIDC and SAML in Enterprise), so even the Teleport password can disappear. See Teleport sign-in.
Helpdesk (password reset and unlock)¶
Off by default. When on, the password-reset identity runs in a separate container (steward-pwd): the console never
holds its password, and that container cannot read the master key or the other identities (it mounts the database read-only and
not /secrets). It has no published port; the console reaches it over an internal Docker network with HMAC-signed,
replay-protected requests. It re-checks every target (managed OU, unchanged DN, standard tier, rate limit) before AD checks its
own ACEs. A reset needs a recent step-up and an explicit confirmation. Passwords are never logged, stored or audited. See
Helpdesk.
The password service does not trust the console's database. It binds with the reset and LAPS identities by simple bind, so
whoever chooses the server it connects to would receive those passwords; and whoever chooses its managed OUs, Tier OUs and
sensitive groups chooses what it may reset. A compromised console could rewrite all of these in the database. So steward-pwd
has its own pinned trust anchors and scope, outside the database and out of the console's reach: the domain
(PasswordService:DomainFqdn, .env STEWARD_PWD_DOMAIN: it fixes the naming context), the DCs (PasswordService:DcFqdns), the LDAPS CA (PasswordService:CaPemFile), the managed OUs (PasswordService:ManagedOuGuids),
plus Tier OUs and extra sensitive groups that always apply (TierOuGuids, SensitiveGroupSids). They live in the instance's
.env and secrets/pwd-ca.pem on the host, written by install.sh. The service connects only to the pinned DCs, verifies their
certificates against the pinned CA only (strict LDAPS, as everywhere), and compares the database with its pins at every
configuration change: another domain, an LDAPS port other than 636, other DCs, another CA or other managed OUs, and it
refuses every operation (it always connects with the pinned domain on port 636 anyway) (trust_mismatch; missing
pins: trust_not_pinned) before reading anything or binding anywhere. The console audits the refusal (failed, reason
trust_mismatch) and shows it to the operator. Tier OUs and sensitive groups the console adds are honoured (more protection);
the pinned ones can never be removed from the console. Pins change only by an explicit administrator step on the host,
install.sh --name <instance> --pin-password-service, which shows the console's values (DC names, CA SHA-256 fingerprint,
managed OU DNs and GUIDs) for review before writing them; never by the console.
LAPS: local administrator passwords¶
Off unless a delegation grants computers.laps.read and the password service runs. The LAPS identity is a dedicated account
held by steward-pwd only (never the console, never the generic read identity: the delegation script refuses a shared account);
its AD right is Read property + Control access on msLAPS-Password (and the legacy ms-Mcs-AdmPwd), inherited by computer
objects only, plus Write property on the LAPS expiration time where "expire now" is delegated. Revealing a password needs:
a delegation covering the computer (VIP-style vip.manage for protected computers), a ticket always, a step-up always,
even when the instance step-up mode is none (there: an authenticator code or the AD password), and a confirmation. Domain
controllers, read-only DCs, delegation-trusted machines, sensitive-group members (Tier 0 servers), adminCount=1 and Tier OUs are
never listed nor revealed, whatever the role; the password service re-checks it with the same classifier. The password crosses the
internal link encrypted for the console, is shown once (copy, hidden after 30 s), and is never stored, logged or audited; the
audit row says who, which computer, the ticket and the outcome. msLAPS-EncryptedPassword (Windows LAPS encryption) is out of
scope and never granted. See LAPS.
Helpdesk toolkit and social engineering¶
The usual attack on a helpdesk is a phone call: someone pretends to be a user (or their manager), invokes urgency and asks for a reset. The console cannot see the caller, so it makes the verification an explicit, audited step:
- Caller verification before a reset (required by default): the operator records HOW the identity was checked — callback on a number already in the directory, manager confirmation, in person with an identity document, or other with an explanation in the ticket. The dialog shows the directory's phone / manager / title so the operator calls back a known number instead of the one given by the caller. Only the method code is audited.
- Every action stays gated: the command palette and its quick actions only open the usual dialogs (delegation, tier and VIP gates on fresh facts, explicit confirmation, step-up); searches and quick-action hints come from the same authorized APIs.
- Passphrases (4 words + 3 digits, ≈ 48 bits) are weaker than the random mode (≈ 95 bits) by design, for dictation: they are one-time passwords, changed at the next sign-in by default, generated server side with a cryptographic RNG, and only guessable online (domain lockout). Choose the random mode for sensitive populations (Configuration › Helpdesk sets the default).
- Ready-to-send message: built in the browser, never stored; it includes the password by default, which is only as safe as the channel it is sent through. Turn Include the password off where messages go through e-mail or chat that others can read.
Ticket / motif privacy. The ticket is free text kept in the audit for the whole retention period and copied to the stdout log (SIEM). It is bounded (100 characters, one line, no control or invisible characters) and the console refuses a ticket containing the new password, but it cannot recognise other personal data: operators must put only a ticket reference or a short reason in it, never health, HR or contact details. It is hashed with the row, so editing or removing it later is detected (and impossible without breaking the chain); retention purges it with the row.
Lockout insight reads badPwdCount, badPasswordTime and lockoutTime on every DC with the read identity (no new AD right)
and needs users.read + users.unlock on the account; refusals are a 404 like a profile. Recently viewed users stay in the operator's browser
(localStorage, account GUID and display name only, at most 10) and are erased at sign-out; a shared workstation should still
use separate OS sessions.
Disable, enable and account expiry¶
- Disable / enable are delegable actions (
users.disable,users.enable) with the same gates as a reset: scope, tier (never privileged or unclassifiable), VIP needsvip.managein the same delegation, recent step-up, explicit confirmation, and never the operator's own account. They exist only with the helpdesk. - Why the password service: Write property on
userAccountControllets its holder set any account flag (DONT_REQUIRE_PREAUTH,PASSWD_NOTREQD…), and AD cannot restrict it to one bit. The console process therefore never holds that right: only the reset account, isolated insteward-pwd, does (the delegation script refuses it for any other identity or for groups). The service flips ONLY the ACCOUNTDISABLE bit, with a compare-and-set (delete the value read + add the new one in one modify): a concurrent change makes AD refuse it. Verified on Samba and Windows Server 2022. - Account expiry (
accountExpires) is a date attribute written by the write account like any other (it cannot change other flags). The end of the chosen day is stored in the instance's configured time zone (UTC by default).
Account creation from a template (onboarding)¶
- Templates are configuration of the console (
delegations.manage+ step-up, audited): a plain OU of the managed OUs (never a protected container,CN=Users, a critical system object or a Tier 0/1 OU), naming rules, catalog defaults, groups that the group classifier says are ordinary (never protected, VIP or privileged), a UPN suffix of the instance. users.createis a delegable action on the template's OU, withusers.readin the same delegation. A creation is decided all or nothing before any write:users.createon the OU,users.attributes.writefor each attribute it writes,groups.members.writeon each group (single-delegation rule each time), recent step-up, explicit confirmation, per-operator rate limit. Names are checked against the whole domain with the read account (escaped filters).- Who writes what: the write account creates the user disabled (
userAccountControl514: no other flag), without a password; the reset account (isolated password service) sets the first password and enables it; the write account adds the groups. The account is re-read by GUID and must be standard before it is enabled. Any later failure removes the groups, keeps the account disabled and is reported step by step; an enabled account always has a password the operator received. - Owner rights: the creator owns what it creates, and an owner can change permissions. The delegation script adds an OWNER RIGHTS ACE (read permissions only, user objects) so the write account cannot later grant itself rights on the accounts it created (verified on Windows Server 2022; Samba ignores OWNER RIGHTS).
- Audit: template, OU, names, values, groups, must-change, password mode (never the password), every step, the group additions and their rollback.
Group membership¶
Operators with groups.read see the groups of the OUs their delegation covers, and with groups.members.write they add or
remove user members. With groups.create, groups.attributes.write and groups.delete they create standard groups, edit
their description, manager, mail and notes, and delete them (see below). The console never renames or nests groups and never
changes a group's type or scope after creation.
- Protected groups are never touched: default and configured sensitive groups and every group nested in one (its members
would become privileged), VIP groups and groups nested in them,
adminCount=1, well-known and built-in groups (RID below 1000, system groups), critical system objects, and groups in a Tier 0/1 OU. A group that cannot be classified is refused. These groups are not listed either. - Members: only standard user accounts the operator may already see (
users.read); never a privileged or unclassifiable account, never the operator's own account. A VIP member needs one delegation that holdsgroups.members.writeandvip.manageand covers both the group and the member. - AD: the write account needs Write property on
member, inherited by group objects only, on the OUs of the group delegations (the export andGrant-StewardDelegation.ps1do it). AdminSDHolder-protected groups do not inherit it. - Audit: every add and remove (also refusals and "nothing to do") with the group, the member's objectGUID and the membership before and after.
Moves and renames¶
- Move (
users.move): decided on fresh facts read by GUID on both sides: the account (scope, tier gate, VIP override, never the operator's own account) and the destination (a plain OU below the managed OUs, never a container, a critical system object or a Tier 0/1 OU, covered by ausers.movedelegation, withvip.managetoo for a VIP account). Moving an account between two scopes therefore needsusers.moveon both: a narrow role cannot push an account out of its own reach into someone else's, or pull one in. - Rename (
users.rename,users.rename.login): the names change only on accounts the operator may manage (same gates), never their own. The sign-in names are a separate action, need a recent step-up (they change how the person signs in) and are checked for uniqueness domain-wide, any object class. Writes are compare-and-set on the values read just before. A new UPN must end with one of the configured UPN suffixes (upn_suffix_not_allowed); asAMAccountNameending with$is refused (sam_dollar), and one equal to an existing computer'ssAMAccountNamewithout its$counts as taken (noPac, CVE-2021-42278/42287; account creation applies the same rule). - AD: the write account holds Create user objects where accounts may land, Delete inherited by user objects (never Delete child on the OU, so AdminSDHolder-protected accounts can never be moved) and Write property on the naming attributes.
Group lifecycle and "the groups I own"¶
- Create (
groups.create): in an OU of the scope with the same container rules as account creation; the name and the sAMAccountName are checked domain-wide. The write account gets Create group objects and an OWNER RIGHTS ACE (Windows) so it cannot later rewrite the permissions of the groups it created. - Edit (
groups.attributes.write): description, manager (managedBy), mail, notes only, compare-and-set. The manager is chosen by objectGUID and must be a standard person account the operator may read; an operator can never make themselves the manager (that could hand them the members, below). - Delete (
groups.delete): never a protected, privileged, VIP-chain, built-in or well-known group (the group gate); a group with members is deleted only after the operator saw them (those they may read, plus a count of the others) and confirmed; step-up; Delete inherited by group objects (AdminSDHolder-protected groups do not inherit it). - "The groups I own" (
groups.owned.members.write, off by default): a delegation flag that lets an operator change the members of the groups of its scope whosemanagedByis the operator's own AD account (resolved server-side from the session's account, compared with the DN AD stores). It never widens the scope, the group gate still applies, and the member side is unchanged (users.readon the member, never oneself, VIP override). Risk: whoever can setmanagedBydecides who manages a group: an operator holdinggroups.attributes.writeon the same groups, or anyone with that right in AD. Grantgroups.attributes.writeto fewer people thangroups.owned.members.write, reviewgroups.attributes.writerows that changemanagedBy, and keep groups that grant power (named in ACLs, GPOs, shares) out of such scopes. Teleport operators are matched only when their Teleport names resolve to their AD account.
Profile data: status, photo, manager, history, export¶
- Who sees what. The account status (enabled, expiry of the account and the password, password last set, bad passwords,
created and modified) needs
users.readon the user: a helpdesk needs it to explain a failed sign-in. The last sign-in is behavioural personal data: it also needsusers.attributes.readonlastLogonTimestamp. The photo needsusers.attributes.readonthumbnailPhoto; the manager and the direct reports need it onmanager, and a direct report is listed only when the operator may read that user too. Last sign-in and photo are read-only in the console: no delegation can write them, and the delegation export never asks AD for a write ACE on them. - Last sign-in is approximate. It comes from
lastLogonTimestamp, which AD replicates only every 9 to 14 days. The console says so; it is not an activity log. - Photo. Served by the console itself (
GET /api/users/{id}/photo) with the same authorization as the attribute, only as JPEG (the bytes are checked), at most 100 KB, withCache-Control: no-storeandnosniff. The CSP is unchanged (img-src 'self'). Anything else stored inthumbnailPhotois never served: the initials are shown instead. Allowed photo reads are not audited one by one (the audited profile read liststhumbnailPhoto); refused ones are. - Manager. The browser sends the chosen user's
objectGUID, never a DN. The console resolves it on the server to an existing person account the operator may read (managed OUs, standard tier), stores the DN AD returned, and audits the old and new values as "display name (GUID)". Unknown, privileged, out-of-scope or computer accounts are refused with one answer. The value the browser says it saw is never looked up: it is compared with the current manager read on the server (409 conflictotherwise), and the current manager is named in the preview and the audit only when the operator may read that account ("(hidden)" otherwise), so the edit is no name or GUID oracle. - History. The profile's History tab shows the audit rows targeting the user and the group membership changes where the
user is the member; it needs
audit.read, like the Audit page. - CSV export. The Users page exports the current search on the server: the same search and the same per-user decision as
the screen, at most 1000 rows, only the columns the operator may read (a cell stays empty where no delegation covers that
user). Cells that a spreadsheet would run as a formula (
= + - @, tab, carriage return) are prefixed with an apostrophe. Every export is audited (users.export, columns and row count). An exported file leaves the console's control: treat it as personal data (storage, sharing, deletion) under your data-protection policy, and grantusers.attributes.readon contact attributes only to operators who may take such data out. - Watch list export. Each watch list can be exported as CSV on the server: exactly the rows the list shows (same query,
same per-user
users.readdecision, privileged accounts never listed), summary columns only (no catalog attribute), at most 1000 rows, with the same formula-injection protection. Audited asusers.watch.export(list and row count, never names); a refusal is audited too. - Users table view. The compact table of the Users page adds department and title. They are read only when one of the
operator's delegations grants
users.attributes.readon them, and each cell is decided per user by the engine (empty elsewhere); such searches record the attributes read in theirusers.searchaudit row.
Protection lists, approvals, access reviews, imports¶
- Protection lists (Tier 0/1 OUs, sensitive groups, VIP groups): adding entries is a
settings.managechange; REMOVING one unprotects accounts at once, so it needs a Super administrator (refusals auditedsuper_administrator_required) and raises the critical notificationprotection_weakened. - Approvals: at approval time the approver must be in the approver list captured with the request AND in the list the CURRENT
approval settings, delegation approval options and requester grants would name (
not_an_approver:current_settings); the higher of the two approval counts applies. The approved request's id is passed explicitly to the decisions of its execution (no ambient flag that could flow into unrelated work), and is honoured only while that request is pending or executing. - Access reviews: a campaign (member names, decisions, export) is visible to and managed by its creator and the Super
administrators only (others:
404). A reviewer's decision is refused (503) when their own AD accounts cannot be resolved (the membership could be theirs). - CSV onboarding import: each row counts against the per-operator creation limit (
Onboarding:MaxCreationsPerOperator). - Baselines: a baseline that LOWERS the step-up mode is applied only by a Super administrator with an explicit confirmation
(
confirmStepUpWeakening); roles with powerful grants in a baseline need a fresh proof even in step-up modenone.
Directory settings after setup¶
- Changing the directory connection (DCs, LDAPS port, CA, UPN suffixes, technical accounts, managed OUs) needs
settings.manageAND a recent step-up; the API decides, the page only helps. There is no "ignore certificate" option and plain LDAP (389 / 3268) is refused. - Nothing is stored before the directory confirms it with the NEW values: TLS with the configured CA and host name, and a bind,
each pinned to one DC (failover cannot hide a bad DC). A new CA must be the one every DC chains to; a PEM holding a private key
is refused. A rotated technical password must bind on every DC that answers; it is stored encrypted like the wizard does, is
never returned by any API, never logged and never audited (the audit says
password:rotated). - Every change is audited with its old and new values (DC names, port, CA subject and SHA-256 fingerprint, suffixes, managed OU DNs, technical account UPN). Removing a managed OU or a UPN suffix still in use requires an explicit confirmation after the impact list was shown.
- The domain itself cannot be changed: another domain is a new instance (its own wizard, its own data).
- DC discovery via DNS SRV is a proposal only: a name from DNS is never trusted or added by itself; it must pass the TLS and bind checks when an administrator adds it. TLS still verifies the configured name, so a spoofed DNS answer cannot impersonate a DC without a certificate from the configured CA.
Super administrator role (built-in)¶
- What it grants: every application permission and ONE delegation with every directory action (
vip.manageincluded) and every catalog attribute, on every managed OU and its sub-OUs. It is never stored as a copy: the server computes it at every decision from the CURRENT catalog, permission list and managed OUs, so a new managed OU, attribute or action is covered at once (and an OU removed from the boundary is gone at once). Stable id00000000-0000-4000-8000-000000000001, keysuper_administrator; the name is translated in the UI. - Hard limit (unchanged): it goes through the same engine as any role. Privileged, Tier 0/1, protected (
adminCount=1) and unclassifiable accounts and groups, objects outside the managed OUs, read-only attributes, and the operator's own account (disable/enable, own group memberships) stay refused. Nothing overrides the tier gate. The simulator says so (super_administrator_never_privileged) and names the role when it is the one that allows (granted_by_super_administrator). - Read-only: it cannot be edited (not even its description), deleted, renamed, replaced by
import-roles, and its name is reserved. It can be assigned and unassigned (Delegations › Operators, Teleport mappings, or the setup wizard opt-in). - Sensitive change both ways: granting OR removing it always needs a step-up (even for an administrator who already holds
delegations.manage), and is audited (operators.roles_changed/operators.createdwithsuper_administrator:grantedorsuper_administrator:revoked). It counts as a delegation administrator for the "last administrator" guard. - Setup wizard: the first-admin step has an opt-in "Also make this administrator a Super administrator", unchecked by default;
when ticked the completion row (
setup.completed) recordssuper_administrator:granted. - AD still decides: the AD delegation export (Configuration › Diagnostics › Export AD delegation) includes what the role needs ONLY while
someone holds it (least privilege): write rights on every catalog attribute present in the schema, reset/unlock/disable/enable and
account creation with the helpdesk, group membership, on every managed OU (sub-OUs included). After assigning it, export and have
the file applied with
Grant-StewardDelegation.ps1; diagnostics show "export out of date" until then and list who holds the role.
Step-up mode (AD operators)¶
Configuration › Sign-in › Step-up mode (settings.manage), instance-wide, for AD operators only (Teleport operators keep their
Teleport session as step-up, whatever the mode):
| Mode | What an AD operator gives before a sensitive change | Use |
|---|---|---|
authenticator (default) |
A code of their authenticator app (TOTP, above) | Recommended |
password |
Their AD password again (bind as their own account by objectGUID, login budgets and lockout-safe throttling; audited auth.reauth / password, never the password). No authenticator needed (enrolment stays possible, ready for a switch back) |
Weaker: a phished, reused or shoulder-surfed password passes the step-up too |
none |
Nothing. Each sensitive change it lets through is still audited (auth.reauth, reason stepup:none, same correlation id, only when the change goes through) |
Not recommended: a hijacked or unattended AD session can grant rights (Super administrator included), switch protections off and reset passwords with no further proof |
- Changing the mode always needs a step-up of the CURRENT mode (lowering it from
authenticatorneeds a code, frompasswordthe password), the UI asks the administrator to acknowledge the risk before lowering it, and every change is audited (settings.step_up_mode,from:/to:, reasonweakenedorstrengthened). - The step-up dialog,
/api/me(stepUp:totp,totp_enrol,password,none), the Security page and the authenticator prompts follow the mode: inpasswordandnonenobody is asked to enrol an authenticator. - Diagnostics show the active mode:
okforauthenticator, a warning forpasswordandnone.
Residual risks (accepted, documented)¶
| Risk | Mitigation in place | Remaining exposure |
|---|---|---|
| Root on the VM | File permissions; the audit chain makes changes detectable; audit forwarding makes them recoverable | Root can read /secrets and therefore the service-account passwords. Restrict and monitor host access |
| Read and write accounts in one process | Separate AD rights per account | The read and write accounts share the console process. The password-reset account is isolated in its own container (helpdesk) |
| Console compromised, helpdesk on | The password service re-checks OU, DN, tier and rate against its own pinned DCs, CA and scope (a database pointing elsewhere is refused); AD checks the reset account's ACEs | Resets, disables and enables of standard accounts in the pinned managed OUs, within the rate limit, until detected (audited and logged by the service); never another account flag, never a bind toward a server the pins do not name |
| Administrator re-pins without reading what is shown | --pin-password-service prints the DC names, the CA fingerprint and the managed OU DNs, and asks for confirmation |
Pinning values a compromised console stored would hand it the pins: compare them with what you configured before confirming, and re-pin only after a change you made |
| Reset account password stolen | Isolated container, file permissions | Write property on userAccountControl allows any flag on accounts of the delegated OUs: grant disable/enable only where needed and monitor such changes in AD |
| AD login without MFA | Throttling below the lockout threshold; authenticator code for every sensitive change | A stolen AD password opens a read session: keep AD sign-in on the client's network, and use Teleport (MFA) or Cloudflare Access for anything beyond it (Exposure) |
| Console behind a proxy or tunnel | Client address from configured trusted proxies only (--trusted-proxy; automatic for Cloudflare Tunnel); identity checked in front (Access, Teleport) |
A proxy left unconfigured means one sign-in budget for everyone behind it; a trusted proxy that forwards a client-chosen X-Forwarded-For untouched lets clients spread their attempts (configure it to set the header); the proxy or tunnel provider sees the traffic in clear (TLS terminates there) |
| An unattended, open Teleport session can make sensitive changes (no second confirmation in the console) | The session is bound to Teleport's JWT on every request; Teleport enforced MFA at sign-in; every such change is audited stepup:teleport with its correlation id; console idle timeout (30 min) |
Anyone at an unlocked workstation with an open Teleport app session acts as that operator. Keep Teleport's session TTL and idle timeout short (max_session_ttl, client_idle_timeout / disconnect_expired_cert), lock workstations, and optionally require per-session MFA (require_session_mfa) on the roles that grant the console so each app launch asks for the security key |
| An operator with reset/disable/rename/move/member rights over another operator's AD account | Operator-takeover protection (only an operator holding all of the target's console rights, a Super administrator's account only by another; never one's own); second factor at sign-in, required for every AD operator holding a role (mode authenticator); Teleport operators' AD accounts resolved from the JWT and protected the same way; resetting an authenticator needs the same dominance |
Through AD itself (outside the console), or in modes password/none (diagnostic warning). Put operator accounts in a VIP/protected group |
| First authenticator enrolment of an AD operator | Needs the AD password (bind, login budgets); for an operator holding a role it happens inside the sign-in, before any session; audited (auth.totp.enrol, started:sign_in) |
Whoever knows the password of an operator who holds a role but never enrolled (e.g. right after an upgrade or a reset) can enrol first: have operators sign in as soon as they get a role, review auth.totp.enrol and the signin_enrolment_pending diagnostic |
| Teleport operator whose AD account has another name | username and windows_logins resolved in AD |
Not recognised as their own account: set windows_logins in Teleport |
| Exclusions | Enforced by the console | AD ACEs cover the whole OU |
| A group grants power the console cannot see (named in an ACL, a GPO, a share) | Protected groups refused; tier OUs | Delegate groups.members.write only on OUs that hold business groups; put admin groups in a tier OU or a sensitive group |
Write account stolen with users.create granted |
Create user objects on the delegated OUs only; OWNER RIGHTS ACE (Windows) | Accounts can be created there, enabled and with a known password, outside the console's audit: grant users.create narrowly, monitor creations (4720). On Samba the creator also keeps "change permissions" on the accounts it created |
Write account stolen with users.move or groups.delete granted |
Delete only on the objects of the delegated OUs (inherited: AdminSDHolder-protected objects excluded); Create only there | The holder can delete standard accounts (with users.move) or groups (with groups.delete) of those OUs outside the console: grant them narrowly, keep the AD recycle bin on, monitor deletions (4726, 4730/4734/4758) without a matching audit row |
managedBy used to hand over a group (groups.owned.members.write) |
Off by default; scope and group gate unchanged; no self-assignment through the console | Anyone who can write managedBy (console or AD) chooses who changes the members: see "the groups I own" above |
| Change then change back between read and save (ABA) | Compare-and-set on values | Not detected; acceptable for single-valued business attributes |
| Super administrator held by someone | Opt-in only; step-up and audit to grant or remove; tier gate unchanged; AD rights only while held; diagnostics list the holders | Whoever holds it (or hijacks their session) can change, reset, disable and create every standard account and group of the managed OUs, VIP included. Keep it to one or two named people, prefer narrow roles for daily work, and review super_administrator:granted |
Step-up mode password or none |
Opt-in, behind the current mode's step-up, audited, diagnostic warning; Teleport operators unaffected | password: one stolen AD password is enough for sensitive changes. none: an open AD session is enough. Prefer authenticator, or Teleport (MFA) for operators |
| LAPS password revealed to a helpdesk operator | Delegation per OU, protected computers need vip.manage, ticket + step-up always (also in mode none), rate limit per computer, audited without the secret; never DCs or Tier 0 servers |
The operator holds a working local administrator password of that computer until it rotates: delegate computers.laps.expire too and expire it after the intervention, keep the LAPS password age short in the GPO, and review computers.laps.read. Whoever sees the screen or the clipboard in those 30 s sees it too |
| Console compromised, LAPS on | The console never holds the LAPS identity; the password service re-checks managed OU, DN, computer tier and rate, with its own pinned DCs, CA and scope | Clear-text LAPS passwords of standard computers in the LAPS scopes, within the rate limit (MaxLapsReadsPerTarget, per computer), until detected; every read is logged by the service (laps_operation) and audited by AD if you audit reads of msLAPS-Password |
| LAPS account password stolen | Isolated container, file permissions, rights on computer objects of the LAPS scopes only | Clear-text LAPS passwords of every computer in those scopes, outside the console: restrict the LAPS scopes to workstations, audit msLAPS-Password reads (directory service access auditing with a SACL), and prefer short password ages |
Set-LapsADComputerSelfPermission or Windows LAPS encryption misconfigured |
Diagnostics prove the read right on a sample computer (names-only read) and flag encrypted-only computers | A computer that stores only an encrypted password cannot be served (by design); a DC moved into a LAPS scope gets the inherited ACE (the console refuses it; the script warns) |
Verification¶
- Automated tests: the authorization matrix, every endpoint authorized, antiforgery on every state change, injection and special characters.
- Lab tests on two real DCs: Samba, and Windows Server 2022.
- Teleport: end-to-end test on Teleport 18.
- Image and dependencies:
tools/security-scan.sh(Trivy, npm audit, NuGet).