Deploy, audit and maintain an Active Directory tier model — from one script and one JSON file.
Modes · What it deploys · Guardrails · Quick start · Design decisions · Troubleshooting
┌───────────────────────────────────────────────┐
wizard ──▶ │ config/tiermodel.json │ ◀── you edit this
│ the source of truth │
└───────────────────────────────────────────────┘
│ │ │
deploy │ audit │ sync │
▼ ▼ ▼
converges reports the keeps membership
the directory drift current
Tiering an Active Directory is not hard to understand and very hard to finish. The structure can be built in an afternoon; deciding which of nine hundred existing servers belongs to which tier cannot, and the projects that stall usually stall exactly there. ADTierKit is built around that reality — it automates everything that can be automated, refuses to guess at the rest, and tells you plainly which is which.
A guided wizard asks for your naming convention, previews every object it would create, and writes a single configuration file. From then on that file is the source of truth. Everything is idempotent, and everything plans before it writes.
The whole tool is one PowerShell script plus one JSON configuration file. No module to import, no folder structure to preserve, no build step. Copy two files onto a domain controller and run them.
Logon rights are tattooed. Once a logon restriction has applied, disabling the GPO link does
not give the right back — the entry stays in the local security database of every machine
that processed it, and it survives reboots. Recovery needs secedit run locally, which in turn
needs a way in: the console, another machine over the network, or DSRM. Never enable these GPOs
on a domain controller without a second route in, and keep the DSRM password to hand.
Repair-TierLockout.ps1 automates the way back.
Lab-tested, not production-tested. Every mode has been run end to end against a Windows
Server 2025 lab domain, idempotency is verified at Created: 0, and the tier separation was
confirmed with real accounts in both directions. It has never run against a production
directory and has not been reviewed by a second engineer — see Limitations &
notes. Take a system state backup of a domain controller before the first
enforced deployment.
Built with AI assistance. Most of the code and documentation in this repository was written by Claude (Anthropic) in a pair-programming workflow: requirements defined and reviewed by a human, implementation by the model. As with any code you did not write yourself, review it before running it in production.
| Getting there | Understanding it | When you need it |
|---|---|---|
| Modes | Roles | Troubleshooting |
| What it deploys | Ownership | When it goes wrong |
| Guardrails | Design decisions | Checking the code |
| Prerequisites | Configuration reference | Limitations & notes |
| Quick start | Reports and logging | Repository layout |
| What belongs in Tier 0 | License | |
| Rollout order |
For the full walkthrough — every setting the tool writes, the day-to-day working model and the complete rollout playbook — see the Operator's Guide.
| Command | Writes? | Purpose |
|---|---|---|
.\ADTierKit.ps1 |
Interactive wizard. Asks for the naming convention, previews the result, writes the configuration, optionally starts the deployment. Start here. | |
.\ADTierKit.ps1 -Mode Deploy |
Applies the configuration. Plans by default — writes only with -Apply. Staged rollout via -Stage. |
|
.\ADTierKit.ps1 -Mode Audit |
— | Read-only drift and hygiene report with severity classification, including the attack paths into Tier 0 that lie outside the model (DCSync rights, dangerous ACEs, editable Tier 0 GPOs, RBCD, Kerberoastable admins, krbtgt age). |
.\ADTierKit.ps1 -Mode Sync |
✅ | Re-runs only the membership stages. Safe to schedule. |
.\ADTierKit.ps1 -Mode InstallTask |
✅ | Registers a daily scheduled task that runs Sync as SYSTEM. -RequireSignedScript runs it under AllSigned, -PinConfiguration makes it refuse a changed configuration. |
.\ADTierKit.ps1 -Mode Check |
— | Prerequisite check only. |
-Apply before anything is written · ✅ writes to the directory · — read-only
Exit codes 0 success · 1 deploy failures · 2 drift found · 3 prerequisites failed · 4 high severity findings · 5 pinned configuration changed
OU=Tiering
├── OU=Staging neutral landing zone — new computers, administered by no tier
├── OU=Tier-0 control plane — domain controllers, PKI, identity
│ ├── OU=Accounts adm-t0-*, break glass
│ ├── OU=Groups G-T0-Admins · G-T0-Operators · DL-T0-* · every tier's DenyLogon / Exempt-Logon
│ ├── OU=Servers Tier 0 member servers
│ ├── OU=Devices privileged access workstations
│ ├── OU=Service-Accounts
│ └── OU=Staging landing zone before production
├── OU=Tier-1 server plane — applications, databases, file services
│ └── … same shape
└── OU=Tier-2 workplace plane — clients and their administrators
└── … same shape
| Stage | What it creates |
|---|---|
RecycleBin |
Enables the AD Recycle Bin, so a mistake during rollout is recoverable without an authoritative restore. Irreversible and forest-wide. |
OU |
Tier model root plus one branch per tier, each with sub-OUs for accounts, groups, servers, devices, service accounts and staging. Protected from deletion, with Group Policy inheritance blocked. |
Domain |
Sets ms-DS-MachineAccountQuota, redirects the default location for new computer accounts away from CN=Computers, enables replication change notification on the site links. |
Group |
Per tier: global role groups (admins, operators) and domain local access groups (local admins, remote desktop, deny logon). |
Nesting |
Cross-tier nesting — in particular the deny-logon group that holds the other tiers' principals. |
Account |
Disabled template and break-glass accounts, flagged sensitive-and-cannot-be-delegated, optionally added to Protected Users. Passwords exported DPAPI-encrypted. |
Delegation |
Explicit ACEs so each tier administers only its own branch — including the full domain-join permission set, and deliberately excluding WriteDacl and WriteOwner. |
PrivilegedGroups |
Compares Domain Admins, Enterprise Admins, Schema Admins, Key Admins and the Account / Server / Print / Backup Operators against their declared membership. Reports by default, corrects in enforce mode. |
Auditing |
SACL audit entries on the model root and the Domain Controllers OU, so changes to the structure and its delegation produce directory service change events. |
GPO |
Per-tier logon restriction GPOs: deny rights for foreign-tier principals, restricted groups for local Administrators and Remote Desktop Users, UNC hardened paths, plus an exception group per GPO. |
Laps |
Windows LAPS: schema extension, per-tier read and reset permissions, and one policy GPO per tier with its own decryption principal. |
KDS |
KDS root key, the prerequisite for gMSA and dMSA. |
Silo |
One Kerberos authentication policy and silo per administrative tier. Deployed in audit mode by default; enforcement is withheld until Kerberos armoring is in place. Membership is reconciled both ways, and an account that qualifies for two silos is reported rather than moved back and forth. |
The result in ADUC Three tiers, each with the same six sub-OUs. |
The result in GPMC Logon restrictions and a LAPS policy per tier, plus the domain controller baseline. |
The isolation logic is deliberately simple and reviewable: each tier has exactly one deny-logon group, and the GPO denies that single SID the logon types that matter. Changing who is locked out of a tier is a group membership change, not a GPO edit.
DNS zones get created, Group Policy objects get written — jobs somebody has to be allowed to do without being allowed to do everything else.
A role is not one object, which is what makes adding one by hand risky. It is a global group, a disabled template account, a set of ACEs, a membership in the deny logon group of every other tier, and a membership in the tier's authentication silo. Forgetting the deny nesting is the expensive one: the new group can then log on everywhere, and nothing reports it, because from the directory's point of view nothing is wrong. So a role is declared once and expanded into all of it at load time:
"roles": [
{
"name": "DNS",
"tiers": [0],
"roleGroup": "G-{TOKEN}-DNS-Admins",
"templateAccount": "adm-{TOKENLC}-dns-template",
"privilegedGroupNesting": [ { "name": "DnsAdmins" } ],
"delegations": [ … ]
}
]Expansion happens inside Import-TierConfiguration, before validation and before any stage sees
the configuration. Deploy, audit and sync therefore need no knowledge of roles at all — the groups
and ACEs a role produces are indistinguishable from ones written out by hand, and are audited,
synced and reported the same way.
| Field | Default | Effect |
|---|---|---|
tiers |
— | Which tier IDs the role is created in. One role, one group per listed tier. |
roleGroup |
— | Naming pattern. {ID} {TIER} {TOKEN} {TOKENLC} {ROLE} {ROLELC}. |
templateAccount |
none | Creates a disabled template account in the role group, like the tier templates. |
nestIntoForeignDenyGroups |
true |
Nests the role group into the deny logon group of every other tier. |
siloMember |
true |
Adds the role group to the tier's authentication policy silo. |
privilegedGroupNesting |
none | Built-in groups the role is nested into, and which are then watched for direct members. |
delegations |
none | ACEs, in the same format as tiers[].delegations. principal defaults to the role group. |
enabled |
true |
false expands nothing. |
nestIntoForeignDenyGroups defaults to on because the failure mode of forgetting it is silent and
the failure mode of over-denying is a logon error somebody notices within the hour.
privilegedGroupNesting produces two things from one declaration, and they are not the same:
- an entry in
privilegedGroups, which is what watches the built-in group for members that are not supposed to be there - an entry in
builtInNesting, which is what actually performs the nesting, from theNestingstage
The split exists because the privileged group stage runs in report mode by default, where an
absent declared member is reported rather than added — a DNS role would hold no permissions at
all until somebody switched privilegedGroups.mode to Enforce.
The division of labour is strict: Nesting only ever adds what the configuration declares,
PrivilegedGroups only ever removes what it does not. They cannot fight, as long as everything
nested is also declared. Role expansion writes both from one line; a hand-written builtInNesting
entry that gets it wrong is refused rather than left to flap between the two stages.
Two refusals worth knowing:
- A role below the top tier cannot be nested into a privileged built-in group.
DnsAdminsmembers can load a DLL into a service running as SYSTEM on a domain controller. The role group is still created — only the nesting is refused, as a High finding. - A built-in group that does not exist is a state, not a fault.
DnsAdminsappears with the DNS server role; the entry is skipped and reported at Low severity.
In audit mode, a role that exists but is not nested is a Medium finding: configured, not in effect.
Roles reach containers outside the tier model — the DNS server object, the policies container,
AdminSDHolder — through seven additional targetOu values listed under
OU references.
Both are in config/roles.example.json, and both are Tier 0 where they create objects — creating
a DNS zone means being able to create records under _msdcs, and creating a Group Policy object
makes you its owner.
DNS. The goal is an empty DnsAdmins. Its members can load a DLL into a service running as
SYSTEM on a domain controller, and it is neither covered by AdminSDHolder nor marked with
adminCount, so nothing in a standard audit flags it as privileged. The role holds its
permissions by being nested into it, and the group is watched so a direct member becomes a
finding.
DnsAdmins is also the one group here not addressed by SID: created by the DNS server role rather
than the operating system, it has an ordinary RID that differs between domains — but it is not
localised either, which is what makes the name lookup safe there and nowhere else.
Group Policy. The interesting part is what the role deliberately cannot do. The GMPC
permission called Link GPOs grants write access to both gpLink and gpOptions, and write
access to gpOptions is the ability to block inheritance and cancel every policy handed down from
the domain — including the baseline. The shipped delegation grants gpLink for writing and
gpOptions for reading only. The GPMC displays the inheritance state correctly and refuses to
change it.
Edit settings is less satisfying: it maps to write access on all properties, so a delegate can
rename a policy and change its WMI filter, and changing the filter changes which machines it
applies to. The narrower grant that would prevent it is refused by the GPMC, which will not open a
policy it cannot fully write. Auditing is the available mitigation, so $PoliciesContainer carries
a SACL and every rename and filter change is recorded.
Creation stays in Tier 0 for a reason the delegation model cannot work around: an owner holds
WRITE_DAC implicitly, whatever the DACL says. A policy created by a delegated administrator is
permanently re-permissionable by them, and any granular delegation placed on it afterwards is
advisory.
Any GPO in the configuration can now declare its owner and its editors:
{
"name": "T1-Logon-Restrictions",
"delegation": {
"editors": ["G-T1-GPO-Admins"],
"owner": "512"
}
}The owner is compared on every run and reported when it has drifted, because a drifted owner means the granular delegation on that object is no longer binding.
The same problem exists for every object a tier administrator creates in their own branch — a
sub-OU they own is one they can re-permission and move objects into. That is what the Ownership
stage is for.
The delegation model withholds WriteDacl and WriteOwner so that a tier administrator cannot
rewrite the permissions that constrain them. Ownership goes around it: an owner holds
WRITE_DAC implicitly, whatever the DACL says.
Windows takes the owner of a new object from the creator's token. A member of Domain Admins
creates objects owned by Domain Admins; everybody else creates objects owned by themselves. So a
delegated administrator owns everything they create — and an owned sub-OU can be re-permissioned,
have objects moved into it, and be opened to principals from another tier. The granular delegation
is binding right up until somebody creates something.
"ownership": {
"enabled": true,
"mode": "Report",
"owner": "512",
"acceptableOwners": [],
"scopes": ["$ModelRoot"],
"objectClasses": ["user", "group", "computer", "organizationalUnit", "msDS-GroupManagedServiceAccount"],
"maxObjects": 5000
}The stage finds nothing at all on a freshly deployed model — the deployment account created
everything, so everything is owned correctly. It starts finding things the first time the model is
actually used, which is why it runs in Sync as well as in Deploy and Audit.
| Behaviour | Why |
|---|---|
| Report is the default | The first list is worth reading. Reassigning ownership silently on a daily scheduled task is not where this should start. |
| Compliant objects are counted, not listed | One action per object would bury every real finding under several thousand lines saying nothing happened. Only drift is listed, and only the first 100, then a count. |
| Drift in the top tier is High, elsewhere Medium | A Tier 0 DACL is the boundary. Elsewhere an owner can rewrite permissions on objects inside their own tier, which is untidy rather than an escalation path. |
maxObjects stops at 5000 by default |
The check reads the security descriptor of every object in scope. Exceeding the cap is reported as a finding rather than silently truncating and returning a clean result. |
| The declared owner should be a group you are in | WRITE_OWNER alone only permits setting the owner to the caller or to a group the caller belongs to. Domain Admins satisfies that for the account running the tool. |
acceptableOwners exists for the cases that are fine but not the declared owner — objects created
by Enterprise Admins during a forest operation, for instance. Listing them stops the same
finding appearing every day.
Switch mode to Enforce once the list has been reviewed. Nothing about reassigning an owner is
destructive, but it is the kind of change that is easier to explain before it happens than after.
A tool that removes logon rights can lock you out of the domain it is meant to secure. These are the mechanisms that stop that from happening — and every one of them exists in response to a way it actually went wrong.
Before a single logon restriction is written, deployment resolves every deny group recursively
and checks it against two identities that must not lose access: the account running the
deployment, and the built-in Administrator. If either would be denied, the GPO stage is skipped
entirely, and the finding names the policy, the group and the target:
[!] LOCKOUT RISK - the logon restriction stage was not applied
[!] T0-DomainController-Baseline denies logon to DL-T0-DenyLogon on
OU=Domain Controllers,DC=... , which contains the account running this deployment
The check is deliberately narrow, and that is the interesting part. Cross-tier denial is the
whole point of the model: a Tier 0 account is supposed to lose its logon rights on Tier 1 and
Tier 2 systems, and once the top tier group is nested into Domain Admins as the configuration
declares, every Tier 0 account is a Domain Admin sitting in the other tiers' deny groups. A guard
that flagged that would fire on every correctly configured domain — and a warning that always
fires is one nobody reads. So it examines only the policies that reach a machine you would need in
order to fix the result: the domain controller, and the host the script runs from.
-Force overrides it. The override is then recorded as a deliberate decision rather than a
failure, so the next run's summary stays meaningful. Audit mode reports the same check as a high
severity finding, which catches a membership added after deployment.
| Guardrail | What it prevents |
|---|---|
| Plan by default | -Mode Deploy writes nothing without -Apply. A mistyped command line cannot change the directory. |
| The DC baseline names its own allow side | A template that writes only deny entries relies on the allow side being held somewhere else — and on a domain controller that can be an implicit default rather than a policy. The baseline writes SeInteractiveLogonRight and SeRemoteInteractiveLogonRight explicitly, so applying it can never leave the controller without an administrative logon path. |
| No empty rights are ever written | SeSomeRight = with nothing after it does not mean "leave alone", it means "nobody holds this right". A right whose principals fail to resolve is skipped rather than emptied. |
| Enforce mode has three unremovable guards | The built-in Administrator is never removed from a privileged group, nor is the account running the deployment, and Domain Admins is never emptied — if enforcing would leave it without members, the group is skipped and reported. |
| Disabled links stay disabled | Each GPO carries a linkEnabled flag. A link you disabled to get out of trouble is not silently switched back on by the next deployment. |
| The Recycle Bin goes first | The first stage enables the AD Recycle Bin, so a mistake later in the same run is recoverable without an authoritative restore. |
| Tattooing is stated out loud | Every run warns, before writing logon rights, that disabling the link later will not give a removed right back. |
| Silos start in audit mode | Authentication policy silos deploy with enforcement off, so you can watch events 4820 / 4821 before anything is actually denied. |
| No enforcement without armoring | An enforced silo needs Kerberos armoring on the KDC and on the clients, or it refuses logons for the wrong reason. The generated GPOs carry both settings (level Supported), and the Silo stage withholds enforcement until they are configured and deployed — -Force overrides. |
| New machines belong to nobody | Computers joined without a pre-staged object land in OU=Staging below the model root, where every tier's administrators are denied logon and only a join group and the top tier hold rights — not in a tier whose administrators would then own a machine that may turn out to be Tier 0. |
| Break-glass stays outside | The generated break-glass account is excluded from the silo and from Protected Users by design. |
| No silent failures | Every failure path writes to the log, not just to the report object. A problem that only shows up as a number in the summary is a problem nobody finds. |
And if it goes wrong anyway, Repair-TierLockout.ps1 is the way back.
| Requirement | Detail |
|---|---|
| Domain functional level | 2012 R2 minimum. 2016+ for authentication policy silos and LAPS password encryption. |
| PowerShell | 5.1 or 7.x with the ActiveDirectory and GroupPolicy modules (RSAT). |
| Privileges | Elevated session, member of Domain Admins. Schema Admins additionally for the LAPS schema extension. |
| Access | Write access to \\<domain>\SYSVOL\<domain>\Policies. |
| Host | The Domain stage needs a domain controller (redircmp / redirusr ship with the AD DS role). The Laps stage needs the LAPS module — Windows Server 2022 / Windows 11 22H2 and later. |
Run .\ADTierKit.ps1 -Mode Check to verify all of it, including whether the target domain is the forest root.
1. Unblock the files after copying them to the server:
Get-ChildItem C:\ADTierKit Read the rest on GitHubScan report · 2026-09-25
- ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review — +10 owner has 0 followers; +25 binaries at repo root (ADTierKit.ps1, Repair-TierLockout.ps1, Update-TierConfiguration.ps1)
From the balcony · 2 of 4 clapped
- Princessclapped
Clear demo workflow, idempotent design with plan-before-write, MIT license, declared status, and practical Active Directory automation with minimal dependencies.
- Crusoeclapped
No vulnerable dependencies, clear local-only data model (single JSON config file), no credential harvesting, and transparent about what it does.
Schnitzel and Cap'm Slop read it and passed. Their reasons are on the balcony, with every other verdict.
Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.
report this listing
— log in to report


0 comments
log in to comment.