Field notes · powershell

Designing an access matrix PowerShell can enforce

A useful PowerShell access matrix needs 8 required fields, stable role IDs, synthetic test fixtures, and preflight validation before directory changes.

·7 min read

An access spreadsheet is not automatically an enforceable access matrix. I needed a schema with stable keys, explicit defaults, known target types, and validation that stops before Active Directory changes. Once the matrix became data instead of prose, the provisioning code got smaller and the policy became easier to review.

The matrix still does not decide what access is correct. People do that. Its job is to preserve the approved answer in a form PowerShell can test and apply.

Key takeaways

  • Use a stable role ID as the key and keep the display name editable.
  • Separate baseline groups from individual exceptions.
  • Represent “no access” explicitly rather than through a missing property.
  • Validate every target object before changing a user.
  • Reject unknown roles and ambiguous mappings.
  • Use synthetic fixtures for tests, never copied production group names.
  • Preview an exact change set before enforcement.

What fields belong in an enforceable role record?

An enforceable record needs enough information to identify the policy, place the account, apply baseline access, and explain special handling. I use eight core fields: role ID, display name, department code, target OU, security groups, mail group, enabled state, and an operator note.

Field Purpose Validation
RoleId Stable automation key Unique, lowercase, constrained pattern
DisplayName Human-readable label Required, can change without breaking callers
DepartmentCode Directory and reporting value Must come from an approved set
TargetOU Account placement Must resolve to one allowed OU
SecurityGroups Baseline access groups Array, every name resolves uniquely
MailGroup Default communication group Empty or one known target
Enabled Whether new requests may use the role Boolean
Note Manual or special handling Optional, visible in preview and result

The stable identifier matters more than it first appears. Names such as “Senior Clerk” or “Field Technician” change. If the intake form and automation use that label as the key, a harmless wording edit can break pending requests or create a second almost-identical role.

I use a durable machine key such as parks-field and keep the label separate. The approved role-based access control loop passes only known role IDs from intake to enforcement.

Why use a PowerShell data file?

A .psd1 data file gives the script a reviewable hashtable without requiring a database or executing arbitrary configuration code. It works well for a small, version-controlled map whose changes should be visible in an ordinary diff.

Microsoft documents that Import-PowerShellDataFile imports hashtable values without invoking the file’s contents. That is safer than using Invoke-Expression to load configuration.

A synthetic matrix can look like this:

@{
    SchemaVersion = 1
    Roles = @{
        'parks-field' = @{
            RoleId         = 'parks-field'
            DisplayName    = 'Parks Field Staff'
            DepartmentCode = 'PARKS'
            TargetOU       = 'OU=Field Staff,OU=Users,DC=example,DC=gov'
            SecurityGroups = @(
                'APP-WorkOrders-User'
                'FILE-Parks-Modify'
            )
            MailGroup = 'MAIL-Parks'
            Enabled   = $true
            Note      = $null
        }
        'seasonal-assistant' = @{
            RoleId         = 'seasonal-assistant'
            DisplayName    = 'Seasonal Assistant'
            DepartmentCode = 'ADMIN'
            TargetOU       = 'OU=Seasonal,OU=Users,DC=example,DC=gov'
            SecurityGroups = @()
            MailGroup      = $null
            Enabled        = $true
            Note           = 'Confirm application access manually.'
        }
    }
}

An empty array means the role deliberately has no baseline security groups. A missing SecurityGroups field means the record is invalid. That distinction prevents a typo from becoming a silent least-access result that nobody notices.

What should matrix validation reject?

Reject duplicate or mismatched role IDs, missing fields, unknown department codes, malformed target OUs, duplicate group entries, unresolved directory objects, disabled roles used by new requests, and collisions where a target name resolves ambiguously.

I validate in two passes:

  1. Schema validation works offline and tests the file’s shape.
  2. Environment validation queries the directory and cloud services read-only to prove every configured target exists and has the expected type.

A PowerShell 5.1-compatible schema check begins like this:

function Test-AccessMatrix {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Matrix
    )

    $errors = New-Object System.Collections.Generic.List[string]
    $required = @(
        'RoleId', 'DisplayName', 'DepartmentCode', 'TargetOU',
        'SecurityGroups', 'MailGroup', 'Enabled', 'Note'
    )

    foreach ($roleKey in @($Matrix.Roles.Keys)) {
        $role = $Matrix.Roles[$roleKey]
        foreach ($field in $required) {
            if (-not $role.ContainsKey($field)) {
                $errors.Add("Role '$roleKey' is missing '$field'.")
            }
        }

        if ($role.RoleId -ne $roleKey) {
            $errors.Add("Role '$roleKey' has a mismatched RoleId.")
        }
    }

    return @($errors)
}

The explicit @(...) return shape matters. PowerShell can turn one output item into a scalar, which makes later count and index checks behave differently from multi-error cases.

This is only the structural half. A separate preflight resolves each target with read-only commands and requires exactly one result. The script should not create a missing group during user provisioning because a misspelled policy target is not authorization to create a new security boundary.

How do defaults and exceptions stay separate?

The matrix contains what every holder of the role should receive. An exception contains an individual grant or omission with its own approval, reason, owner, and review condition. Combining them makes the baseline impossible to audit and encourages one-off access to spread to future staff.

I keep these concepts distinct:

expected access = enabled role baseline + approved active exceptions

An exception may add a project group, withhold one baseline group temporarily, or record a manual application right. It should not edit the shared role simply because one person needs something different.

The same rule applies to “helpful” discovery. The matrix generator should never look at a current employee and decide that every membership they hold belongs in the role. Current state contains old projects, mistakes, inherited access, and unrecorded emergencies. Discovery can propose a review item, but people must approve policy.

How should PowerShell turn the matrix into a preview?

Resolve the role, user, and every target first, then compare expected direct memberships with current direct memberships. Produce typed Add, Keep, Review, and Manual records before calling a mutating cmdlet.

A preview object might contain:

[pscustomobject]@{
    User       = 'sample.user'
    RoleId     = 'parks-field'
    Action     = 'Add'
    TargetType = 'ADSecurityGroup'
    Target     = 'FILE-Parks-Modify'
    Reason     = 'Baseline role membership is missing.'
}

The operator can export or display the complete list. Enforcement then consumes that reviewed list rather than recomputing policy halfway through mutations.

For functions that change state, I use SupportsShouldProcess and call $PSCmdlet.ShouldProcess() around each target. Microsoft’s ShouldProcess guidance explains how this supplies -WhatIf and -Confirm behavior.

Preview is not authorization by itself. The application still needs an approved request and an operator with appropriately limited credentials. -WhatIf shows what code intends to do; it does not prove the policy is correct.

What test fixtures catch matrix mistakes?

Use a completely fictional directory namespace and test both valid and invalid records. Fixtures should cover empty baselines, duplicate group names, unknown roles, missing fields, disabled roles, ambiguous directory results, and a role whose display name changes while its ID remains stable.

My minimum tests are:

  1. A valid role produces no schema errors.
  2. A missing SecurityGroups field fails while an empty array passes.
  3. A mismatched role key and RoleId fails.
  4. Duplicate group names fail before directory lookup.
  5. An unknown department code fails.
  6. A disabled role cannot serve a new request.
  7. Zero or two directory matches both fail preflight.
  8. The preview contains no mutation for existing membership.
  9. -WhatIf makes no directory changes.
  10. One failed group addition does not erase the result of earlier successful steps and is clearly reported.

I never copy production group names, user identities, domains, or email addresses into public examples. Synthetic fixtures make the article reproducible without publishing the access design it is meant to protect.

How should matrix changes be reviewed?

Review the policy meaning and the technical diff separately. A valid data file can still grant the wrong access. The reviewer should see the role’s old and new targets, affected future requests, owner approval, and a preview against a synthetic or test identity.

I treat these as high-impact changes:

  • Adding a privileged or broad group.
  • Changing the target OU.
  • Reusing a role ID for a different job.
  • Removing a baseline group from an active role.
  • Changing an exception into a default.
  • Allowing an unresolved target to pass validation.

After approval, I run the full matrix validator and the provisioning tests. A green parse check proves syntax only. The important test is that the expected change set is correct and no mutation occurs during validation or preview.

Frequently asked questions

An enforceable matrix is small policy-as-data. Its value comes from explicit meaning, validation, review, and restrained enforcement.

Should the access matrix be a spreadsheet instead?

A spreadsheet can be the review interface, but convert it through a validated pipeline into one canonical schema. Do not let column names and blank-cell behavior become implicit runtime policy.

Can display names be used as role keys?

Avoid it. Display names change for understandable business reasons. Stable role IDs let labels change without breaking pending requests, tests, or audit history.

Should validation create missing groups automatically?

No. A missing target is a policy or environment error. Group creation changes the security model and deserves its own explicit approval and ownership.