adpwsh

package module
v0.23.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 22, 2026 License: MIT Imports: 9 Imported by: 0

README

go-adpwsh

A Go library that drives Active Directory through the ActiveDirectory PowerShell module — on the Windows host the caller runs on, on a Windows jump box reached over SSH, or on a Windows host reached over WinRM/PSRP.

It is a separate repository from the Terraform provider that consumes it for one reason: managing AD from Go is useful without Terraform, and a library that cannot return a diag.Diagnostics is a library whose correctness rules cannot quietly become someone else's problem. A test in this module fails the build if any Terraform package enters the import graph, including through test imports.

Topology

Three transports, one contract. All three invoke the same fixed command with the same JSON payload on stdin, and all three hand stdout, stderr and the exit code back verbatim.

On-host — transport/local. The caller already runs on a domain-joined Windows host, so the process holds a Kerberos TGT for whoever launched it and there is no hop to authenticate.

caller (Windows host)
   └─ pwsh -EncodedCommand …   (payload on stdin)
        └─ Import-Module ActiveDirectory
             └─ ADWS :9389 ──▶ pinned DC

Remote — transport/ssh. The caller runs anywhere and reaches a Windows jump box over SSH.

caller (anywhere)
   └─ ssh ──▶ jump box
                └─ pwsh -EncodedCommand …   (payload on stdin)
                     └─ Import-Module ActiveDirectory
                          └─ ADWS :9389 ──▶ pinned DC

On Windows, an SSH session authenticated by public key receives a network logon token carrying no delegatable credentials, so onward authentication to ADWS fails — the classic double hop. Over SSH that is worked around with an explicit Config.Credential, which becomes -Credential on every cmdlet. On-host execution removes the problem instead of working around it, and Config.Credential remains available there for the case where the operations must authenticate as some account other than the one that launched the process.

Remote — transport/psrp. The caller runs anywhere and reaches a Windows host over WinRM/PSRP. By default it authenticates with Kerberos over HTTP (port 5985), using the caller's own ambient Kerberos ticket; UseTLS switches it to HTTPS on port 5986. The target host needs the PowerShell.7 WinRM endpoint registered (Enable-PSRemoting run from pwsh 7) and RSAT-AD-PowerShell installed, and a non-admin connecting account must belong to the local Remote Management Users group. The AD cmdlets still reach the domain controller over ADWS (port 9389), so this transport is pointed either straight at a DC or at a member/management host together with domain credentials to cross the same Kerberos double hop transport/ssh works around.

Cost per operation, stated so it is not a surprise. Every operation pays a fresh Import-Module ActiveDirectory, roughly 1–3 seconds on Windows. This is inherent to the one-shot-per-operation execution contract, and it is the same for every transport. Concurrency bounds how many run at once — 4 by default, because each is a real process with real memory cost.

Example

dir := fake.NewDirectory()
client, err := adpwsh.New(context.Background(), adpwsh.Config{Transport: dir.Transport()})
if err != nil {
    panic(err)
}
defer client.Close()

ou, err := client.OU.Create(context.Background(), adpwsh.OUSpec{
    Name:      "Staff",
    Container: client.DefaultNamingContext(),
})
if err != nil && !errors.Is(err, adpwsh.ErrReplication) {
    panic(err)
}
fmt.Println(ou.DN, ou.Protected)
// Output: OU=Staff,DC=corp,DC=local true

That example runs in this module's test suite against transport/fake, so the whole library — and any consumer built on it — is testable with no Windows VM.

What the module guarantees

These are enforced at the module boundary. A consumer cannot opt out of them.

  • Read-back after write. Create and Update return the result of the same read Get performs, so an inconsistent result after apply is impossible by construction.
  • Delete verification. Delete returns nil only after a re-read confirms the object is gone. A Remove-AD* that returns cleanly while the deletion was refused is an error, not a success.
  • A pinned domain controller. New resolves one DC and every cmdlet for the client's lifetime carries -Server <that DC>. Without it a create lands on DC-A and the read-back hits DC-B and reports "not found".
  • Serialized writes per target. A read-then-write delta has no compare-and-swap, so writes naming the same object are serialized. Writes naming different objects still run concurrently.
  • Fail-closed classification. An unrecognized (exception type, error code) pair is KindUnknown and is never retried. Only KindTransient is retried: guessing that an unknown error is transient turns a permission problem into a hang.
  • No value ever becomes script text. Scripts are constants selected by a closed set of op names and embedded at build time. Every value travels as JSON on stdin and is splatted into the cmdlet. There is no code path that formats a caller's value into PowerShell.
  • Secrets cannot be printed or marshalled. Secret renders as REDACTED under every fmt verb and its MarshalJSON always fails, so a struct walk into a log line or a state file is a loud error rather than a leak. The payload is masked before a log line is constructed.
  • A replication timeout returns the model and the error. The object exists; only the wait did not finish. Erroring without the model orphans the object, so Create and Update may return a non-nil model beside a non-nil ErrReplication. Persist the model and surface the error.

Extension seams

  • Transport is the only I/O seam. Four ship: transport/local (pwsh as a child process of the caller), transport/ssh (a Windows jump box), transport/psrp (WinRM/PSRP to a Windows host), and transport/fake (a programmable double plus fake.Directory, a small in-memory AD). Envelope parsing, error classification, retry and the replication wait all live above it, which is why transport/local inherited every property above without restating one of them, and why transport/psrp did too. No transport can reinterpret an AD refusal as a transport failure.
  • Catalog will be the schema seam. The types have landed — schema.Catalog and its reader — and make schema produces the catalog they read; Config.Catalog has not, and adding it later is additive.

The schema catalog

make schema writes schema/catalog.json, a machine-readable catalog of an Active Directory schema: every attribute's type and constraints, and, for each class it covers, that class's effective set of allowed attributes. schema.Baseline() reads the copy committed with this module, so a consumer reaches a stock catalog with nothing beyond an import — no domain, no transport, no separate download.

Effective, not declared, is the whole point. An object's legal attributes are the union — across the entire inheritance closure — of mayContain, systemMayContain, mustContain and systemMustContain. The closure follows subClassOf up to top and, at every step, auxiliaryClass and systemAuxiliaryClass transitively. Against a stock Windows Server 2025 schema, reading mayContain off the class itself reports 31 attributes for organizationalUnit where the answer is 160, 25 for group where it is 192, and 159 for user where it is 406 — and it under-reports, so a validator built that way rejects attributes Active Directory accepts.

via records which class contributed each attribute, so "why is this attribute allowed here?" is answered by the file rather than by re-deriving the closure.

The committed catalog is a stock baseline, not an authority

It was exported from an unextended forest. Exchange, Skype for Business and Configuration Manager each add hundreds of attributes and several auxiliary classes to the schema they extend — Exchange alone adds roughly a thousand attributes. A consumer that trusts this baseline on an extended forest will under-report. source names the domain, forest mode, schema objectVersion and export time, so a stale or foreign catalog is identifiable without diffing it.

Regenerating

Regenerate after any schema extension, and to produce a catalog for your own forest. Run the exporter directly on the Windows host that has RSAT-AD-PowerShell — Windows generally has no make, so the instruction there is go run, not make schema:

go run ./cmd/adschema export --transport local \
  --server dc01.corp.local --out schema/catalog.json

--transport ssh can now carry this export, and every other operation this library sends. transport/ssh sends short commands inline as pwsh -EncodedCommand <base64>; base64-of-UTF-16 runs about 2.7x the size of the source, and every script this library sends — the preamble and epilogue alone are already close to 4.5KB of source before an op is added — exceeds the roughly 8,191-character command-line limit of cmd.exe, the shell behind sshd's DefaultShell on a Windows host; the schema fetch is merely the largest of them. Once the base64 reaches largeCommandThreshold (7,000 characters — the same cutoff scripts/lab/psrun.sh uses), transport/ssh instead writes the script to a randomly named file under Config.RemoteTempDir (default C:\Windows\Temp) over SFTP on the same SSH connection, runs it with -File, and removes it afterward. This path is covered by an in-process fake SSH server that also serves the sftp subsystem; it has not yet been exercised against a real Windows jump box.

make schema-check regenerates to a temporary file and diffs, which proves the committed catalog still matches the domain — but make itself is generally not on the Windows host, so proving the property today still means running the exporter by hand, twice, passing --exported-at set to the committed file's own source.exportedAt both times (so the diff is of the schema, not the clock), and comparing the two results byte for byte.

adschema export --classes all resolves every structural class instead of the three the provider manages (organizationalUnit, group, user). It costs nothing beyond output size, and the committed baseline deliberately uses the default. A password is read from the environment variable named by --ad-password-env, never from a flag, because argv is visible in the host's process list.

The exporter only reads. Extending a schema is irreversible and forest-wide, and no tool here makes it easy.

Windows requirements

Both transports need the same things of the Windows machine that runs pwsh:

  • A Windows member server — not a domain controller.
  • RSAT-AD-PowerShell installed.
  • PowerShell 7 (pwsh) or Windows PowerShell 5.1 (powershell.exe) on PATH. The scripts no longer use anything 6+ only — no ConvertFrom-Json -AsHashtable, no ?. null-conditional operator — so 5.1 is a supported engine, not a fallback.
  • TCP 9389 open to the domain controller — the AD Web Services port the cmdlets use.

5.1 support is proven, but not uniformly across transports. It is verified over transport/psrp: 34 acceptance-suite runs against a live domain (ten batches, logged batch by batch in the consuming provider's LAB.md), including non-ASCII input in the hostile-input batch, all on Windows PowerShell 5.1. transport/local and transport/ssh deliver the payload by writing raw UTF-8 to the process's stdin, which the script reads with [Console]::In.ReadToEnd(); pwsh defaults that stream to UTF-8, but powershell.exe reads it through the console code page instead. Non-ASCII payloads over local/ssh on 5.1 are therefore unverified — say so rather than claiming 5.1 works identically everywhere. This is not fixed by setting [Console]::InputEncoding in the preamble: that rebuilds Console.In from the real stdin handle, which would break psrp's payload delivery, since it does not deliver input that way.

transport/local needs nothing further: the caller is already on that machine.

transport/ssh additionally needs OpenSSH Server running on it, and TCP 22 open from wherever the caller runs. Host key verification is on by default; insecure_ignore_host_key is an explicit opt-out, and setting two host-key sources is a validation error rather than a silent precedence surprise.

Stability

v0.x. Group membership (Members/MembersRecursive/AddMembers/RemoveMembers/IsMember) is present; Members reads the full member attribute (the ActiveDirectory module performs ranged retrieval internally — the cmdlets reject the LDAP ;range= option), MembersRecursive resolves nesting via Get-ADGroupMember -Recursive (leaf user/computer accounts only — group objects, even empty nested ones, are never returned; primary-group-only membership excluded), and SetMembers is deferred. Bounded, class-scoped search (OU.Search/Group.Search/User.Search) is present as of v0.4.0: each takes a Query — an LDAP filter built through the exported EscapeFilter/Equal/And helpers, a search base, a scope and a size limit — and errors with KindTooManyResults rather than truncating past the limit. The module takes v1 only after the acceptance suite passes against a real domain. Until then, minor versions may change the surface.

Access control is present via ACL.Get, ACL.Grant and ACL.Revoke over explicit ACEs on an object's DACL. Schema resolution (Schema.Resolve) maps friendly schema names to their GUIDs with a well-known fast path. Delegation task expansion (Delegation.Template and Delegation.Tasks) is a pure function that expands a curated delegation task into the ACEs that implement it, with no I/O side effects.

Search is still not an arbitrary directory API: there is no generic object search, and object mutation remains get-by-identity only. The Catalog interface, the generic Object sub-client, and tier-2 Attributes map[string]any are deliberately absent from this release.

ServiceAccount (group Managed Service Account) provides Create, Get, Update, Delete, and Search via ServiceAccountClient. Supported attributes include description, enabled, DNS hostname, service principal names, principals allowed to retrieve the managed password, Kerberos encryption types, and account expiration; managed password interval and trusted for delegation are also present.

Computer provides Create, Get, Update, Delete, and Search via ComputerClient. Supported attributes include description, enabled, DNS hostname, service principal names, constrained delegation (msDS-AllowedToDelegateTo), RBCD (principals allowed to delegate to), Kerberos encryption types, and account expiration; operating system and version are read-only, and trusted for delegation is present.

Licence

MIT.

Documentation

Overview

Package adpwsh drives Active Directory through the ActiveDirectory PowerShell module running on a Windows jump box.

It knows nothing about Terraform. Every correctness rule it enforces — read-back after write, delete verification, pinned domain controller, serialized writes, fail-closed error classification, and the invariant that no value ever becomes PowerShell script text — is a guarantee made at the module boundary, so no consumer can opt out of it.

The read surface includes bounded, class-scoped search: OU.Search, Group.Search and User.Search each take a Query (an LDAP filter built through the exported EscapeFilter/Equal/And helpers, a search base, a scope and a size limit) and return typed results. It is not an arbitrary directory API — there is no generic object search, and object mutation remains get-by-identity only.

As of this version the library also manages object access control: ACL.Get/Grant/Revoke over explicit ACEs (never the whole DACL), Schema.Resolve (friendly name to schema GUID, with a well-known fast path), and Delegation which expands a curated task into the ACEs that implement it.

Example

Example shows the whole contract in one page: a client is a transport plus a pinned DC; every write returns the read path's result; a replication timeout returns the model and an error together.

package main

import (
	"context"
	"errors"
	"fmt"

	adpwsh "github.com/nemethhh/go-adpwsh"
	"github.com/nemethhh/go-adpwsh/transport/fake"
)

func main() {
	dir := fake.NewDirectory()
	client, err := adpwsh.New(context.Background(), adpwsh.Config{Transport: dir.Transport()})
	if err != nil {
		panic(err)
	}
	defer client.Close()

	ou, err := client.OU.Create(context.Background(), adpwsh.OUSpec{
		Name:      "Staff",
		Container: client.DefaultNamingContext(),
	})
	if err != nil && !errors.Is(err, adpwsh.ErrReplication) {
		panic(err)
	}
	fmt.Println(ou.DN, ou.Protected)
}
Output:
OU=Staff,DC=corp,DC=local true

Index

Examples

Constants

View Source
const (
	KindUnknown          = adcore.KindUnknown
	KindNotFound         = adcore.KindNotFound
	KindAlreadyExists    = adcore.KindAlreadyExists
	KindDenied           = adcore.KindDenied
	KindConstraint       = adcore.KindConstraint
	KindPassword         = adcore.KindPassword
	KindReferral         = adcore.KindReferral
	KindTransient        = adcore.KindTransient
	KindTransport        = adcore.KindTransport
	KindInvalidAttribute = adcore.KindInvalidAttribute
	KindSchema           = adcore.KindSchema
	KindReplication      = adcore.KindReplication
	KindTooManyResults   = adcore.KindTooManyResults
	KindUnsupported      = adcore.KindUnsupported

	SearchScopeBase     = adcore.SearchScopeBase
	SearchScopeOneLevel = adcore.SearchScopeOneLevel
	SearchScopeSubtree  = adcore.SearchScopeSubtree

	GroupScopeGlobal      = adcore.GroupScopeGlobal
	GroupScopeDomainLocal = adcore.GroupScopeDomainLocal
	GroupScopeUniversal   = adcore.GroupScopeUniversal

	GroupCategorySecurity     = adcore.GroupCategorySecurity
	GroupCategoryDistribution = adcore.GroupCategoryDistribution

	InheritanceThis        = adcore.InheritanceThis
	InheritanceDescendants = adcore.InheritanceDescendants
	InheritanceChildren    = adcore.InheritanceChildren

	ACEAllow = adcore.ACEAllow
	ACEDeny  = adcore.ACEDeny

	RefAttribute     = adcore.RefAttribute
	RefClass         = adcore.RefClass
	RefExtendedRight = adcore.RefExtendedRight

	TaskResetUserPasswords    = adcore.TaskResetUserPasswords
	TaskManageUsers           = adcore.TaskManageUsers
	TaskModifyGroupMembership = adcore.TaskModifyGroupMembership
	TaskManageGroups          = adcore.TaskManageGroups
)

Variables

View Source
var (
	ErrNotFound         = adcore.ErrNotFound
	ErrAlreadyExists    = adcore.ErrAlreadyExists
	ErrDenied           = adcore.ErrDenied
	ErrConstraint       = adcore.ErrConstraint
	ErrPassword         = adcore.ErrPassword
	ErrReferral         = adcore.ErrReferral
	ErrTransient        = adcore.ErrTransient
	ErrTransport        = adcore.ErrTransport
	ErrInvalidAttribute = adcore.ErrInvalidAttribute
	ErrSchema           = adcore.ErrSchema
	ErrReplication      = adcore.ErrReplication
	ErrTooManyResults   = adcore.ErrTooManyResults
	ErrUnsupported      = adcore.ErrUnsupported
)

Functions

func ACLEndpointHelpers added in v0.18.0

func ACLEndpointHelpers() string

ACLEndpointHelpers returns the PowerShell -FunctionDefinitions block that a ConstrainedLanguage management-host endpoint must install so the provider can run ACL delegation in constrained mode. The Terraform provider embeds a copy in its endpoint-registration script and drift-tests it against this value.

func And added in v0.4.0

func And(terms ...string) string

And composes a conjunction.

func Bool

func Bool(b bool) *bool

Bool is the pointer helper for optional booleans.

func Equal added in v0.4.0

func Equal(attr, value string) string

Equal builds an equality assertion "(attr=<escaped value>)".

func EscapeFilter added in v0.4.0

func EscapeFilter(value string) string

EscapeFilter escapes one LDAP assertion value per RFC 4515.

func Int added in v0.10.0

func Int(i int) *int

Int is the pointer helper for optional integers.

func ParseEnvelope added in v0.2.2

func ParseEnvelope(op string, r Result) (json.RawMessage, error)

ParseEnvelope turns one raw Result into either the operation's data or a classified *Error. A non-zero exit or a missing envelope is KindTransport: the script exits 0 even when AD refuses, so anything else means the transport or the pwsh process itself failed.

It is exported for the build-time tooling in this module that needs a query the op set does not expose — cmd/adschema — so that such a tool inherits this library's error classification instead of inventing its own. It confers no ability to run script text: the caller must already hold a Result.

func String

func String(s string) *string

String is the pointer helper for the tri-state spec fields.

Types

type ACE added in v0.5.0

type ACE = adcore.ACE

type ACESpec added in v0.5.0

type ACESpec = adcore.ACESpec

type ACEType added in v0.5.0

type ACEType = adcore.ACEType

type ACLClient added in v0.5.0

type ACLClient struct {
	// contains filtered or unexported fields
}

ACLClient reads and writes the discretionary ACL of a directory object. It is never authoritative over the whole DACL: Grant adds explicit ACEs and Revoke subtracts the rights it names from the matching explicit ACE, leaving inherited entries and other trustees untouched.

func (*ACLClient) Get added in v0.5.0

func (a *ACLClient) Get(ctx context.Context, target Identity) ([]ACE, error)

Get returns every ACE on target's DACL, including inherited ones (the Inherited flag distinguishes them). The caller matches the explicit ACE it owns and ignores the rest.

func (*ACLClient) Grant added in v0.5.0

func (a *ACLClient) Grant(ctx context.Context, target Identity, aces []ACE) error

Grant adds each ACE to target's DACL. Adding an ACE that is already present is a no-op on the DC, so Grant is idempotent.

func (*ACLClient) Revoke added in v0.5.0

func (a *ACLClient) Revoke(ctx context.Context, target Identity, aces []ACE) error

Revoke subtracts the rights named by each ACE from target's DACL. It uses .NET RemoveAccessRule, so it removes the named rights from the matching explicit ACE (same trustee, type and scope) even when Active Directory has coalesced several grants into one larger ACE — the case RemoveAccessRuleSpecific could not reach, because AddAccessRule unions masks on grant. For an ACE that stands alone, subtracting its whole mask empties and drops it, so the common case is unchanged. Revoking rights that are already absent is a no-op, so Revoke stays idempotent.

type Client

type Client struct {
	OU             *OUClient
	Group          *GroupClient
	User           *UserClient
	ServiceAccount *ServiceAccountClient
	Computer       *ComputerClient
	Schema         *SchemaClient
	ACL            *ACLClient
	Delegation     *DelegationClient
	// contains filtered or unexported fields
}

Client is the entry point. Its sub-clients map one method to one provider resource operation.

func New

func New(ctx context.Context, cfg Config) (*Client, error)

New validates the configuration, resolves the domain controller this client will pin for its lifetime, and proves the jump box can import the ActiveDirectory module. It performs one round trip.

func (*Client) Close

func (c *Client) Close() error

Close releases the transport.

func (*Client) DefaultNamingContext

func (c *Client) DefaultNamingContext() string

DefaultNamingContext returns the domain's naming context, e.g. "DC=corp,DC=local".

func (*Client) Directory added in v0.22.0

func (c *Client) Directory() adcore.Directory

Directory presents this client through the backend-neutral contract. The sub-clients already have the right method sets, so this is a projection, not an adapter: no behaviour is added or changed here.

func (*Client) Server

func (c *Client) Server() string

Server returns the pinned domain controller.

type Computer added in v0.11.0

type Computer = adcore.Computer

type ComputerClient added in v0.11.0

type ComputerClient struct {
	// contains filtered or unexported fields
}

ComputerClient is the computer account (objectClass "computer") sub-client.

func (*ComputerClient) Create added in v0.11.0

func (cc *ComputerClient) Create(ctx context.Context, spec ComputerSpec) (*Computer, error)

Create makes a computer account and returns the result of the same read Get performs. It may return a non-nil Computer together with a non-nil error on a replication timeout; the caller must persist the model and surface the error. OperatingSystem/OperatingSystemVersion/OperatingSystemServicePack are never written here: they are read-only (the joined machine owns them) and are not on ComputerSpec.

func (*ComputerClient) Delete added in v0.11.0

func (cc *ComputerClient) Delete(ctx context.Context, id Identity) error

Delete removes a computer account and returns nil only after a re-read confirms it is gone.

func (*ComputerClient) Get added in v0.11.0

func (cc *ComputerClient) Get(ctx context.Context, id Identity) (*Computer, error)

Get reads one computer account.

func (*ComputerClient) Search added in v0.11.0

func (cc *ComputerClient) Search(ctx context.Context, q Query) ([]Computer, error)

Search returns every computer account under q.SearchBase matching q.Filter.

func (*ComputerClient) Update added in v0.11.0

func (cc *ComputerClient) Update(ctx context.Context, id Identity, spec ComputerSpec) (*Computer, error)

Update folds the attribute write, the rename and the move into one round trip.

type ComputerSpec added in v0.11.0

type ComputerSpec = adcore.ComputerSpec

type Config

type Config struct {
	// Transport is how PowerShell reaches the jump box. Required.
	Transport Transport

	// Dialect selects the script set. The zero value, DialectADWS, preserves
	// the historical behaviour exactly.
	Dialect Dialect

	// Server pins the domain controller every cmdlet targets. When empty it is
	// discovered once in New and never changes for this client's lifetime.
	Server string

	// Credential, when set, becomes the -Credential passed to every cmdlet on
	// the jump box. Omit it to use the transport session's own identity.
	Credential *Credential

	// Retry governs re-attempts, and applies only to errors classified
	// transient.
	Retry adcore.RetryConfig

	// Replication governs the post-write wait.
	Replication ReplicationConfig

	// Log is an optional output port. It is not an extension seam: redaction
	// cannot be the caller's job, because the caller never sees the payload.
	// The library masks credential-bearing keys before anything reaches Log.
	Log Logger
}

Config configures a Client. Transport is the only required field.

type Credential

type Credential struct {
	Username string
	Password Secret
}

Credential is a username and password for the AD cmdlets.

type DelegationClient added in v0.5.0

type DelegationClient = adcore.Delegation

DelegationClient keeps its name here although the type is called Delegation in adcore: it is reached as Client.Delegation, and the provider names the type directly.

type DelegationTask added in v0.5.0

type DelegationTask = adcore.DelegationTask

func Tasks added in v0.5.0

func Tasks() []DelegationTask

Tasks returns every delegation task name, in a stable order.

type DeleteOptions

type DeleteOptions = adcore.DeleteOptions

type Dialect added in v0.21.0

type Dialect int

Dialect selects which PowerShell script set an operation runs.

The zero value is DialectADWS: Microsoft's ActiveDirectory module over AD Web Services, which requires a Windows host. DialectPSOpenAD runs the PSOpenAD module over LDAP and works anywhere PowerShell 7.4 does, including Linux.

There is deliberately no auto-detection. A dialect decides which module executes, and guessing it is the same class of mistake as guessing a transport.

const (
	DialectADWS Dialect = iota
	DialectPSOpenAD
)

func (Dialect) String added in v0.21.0

func (d Dialect) String() string

type Error

type Error = adcore.Error

type GMSA added in v0.10.0

type GMSA = adcore.GMSA

type GMSASpec added in v0.10.0

type GMSASpec = adcore.GMSASpec

type Group

type Group = adcore.Group

type GroupCategory

type GroupCategory = adcore.GroupCategory

type GroupClient

type GroupClient struct {
	// contains filtered or unexported fields
}

GroupClient is the group sub-client.

func (*GroupClient) AddMembers added in v0.3.0

func (g *GroupClient) AddMembers(ctx context.Context, group Identity, members []Identity) error

AddMembers adds each member to the group. It is idempotent: a member already present is not an error. A member that does not exist is a real error and is surfaced.

func (*GroupClient) Create

func (g *GroupClient) Create(ctx context.Context, spec GroupSpec) (*Group, error)

Create makes a group and returns the result of the same read Get performs. It may return a non-nil Group together with a non-nil error on a replication timeout; the caller must persist the model and surface the error.

func (*GroupClient) Delete

func (g *GroupClient) Delete(ctx context.Context, id Identity) error

Delete removes a group and returns nil only after a re-read confirms it is gone.

func (*GroupClient) Get

func (g *GroupClient) Get(ctx context.Context, id Identity) (*Group, error)

Get reads one group.

func (*GroupClient) IsMember added in v0.3.0

func (g *GroupClient) IsMember(ctx context.Context, group, member Identity) (bool, error)

IsMember reports whether member is a direct member of group without enumerating the group. A not-found group or member reads as "false": the edge cannot exist, which is drift the caller reconciles rather than an error.

func (*GroupClient) Members added in v0.3.0

func (g *GroupClient) Members(ctx context.Context, group Identity) ([]Member, error)

Members reads a group's full membership. It pages the multivalued member attribute so the result is correct for a group of any size.

func (*GroupClient) MembersRecursive added in v0.7.0

func (g *GroupClient) MembersRecursive(ctx context.Context, group Identity) ([]Member, error)

MembersRecursive reads a group's effective membership: the leaf accounts (users and computers) reachable through nested groups, matching Get-ADGroupMember -Recursive. Group objects are traversed but never returned — not even an empty nested group (confirmed against a real domain). Primary-group-only membership (e.g. a user's primary Domain Users) is not included — it is stored via primaryGroupID, not the member attribute.

func (*GroupClient) RemoveMembers added in v0.3.0

func (g *GroupClient) RemoveMembers(ctx context.Context, group Identity, members []Identity) error

RemoveMembers removes each member from the group. It is idempotent: a member not present is not an error, and a not-found group is success — the edges are gone regardless.

func (*GroupClient) Search added in v0.4.0

func (g *GroupClient) Search(ctx context.Context, q Query) ([]Group, error)

Search returns every group under q.SearchBase matching q.Filter.

func (*GroupClient) Update

func (g *GroupClient) Update(ctx context.Context, id Identity, spec GroupSpec) (*Group, error)

Update folds the attribute write, the rename and the move into one round trip. Where AD refuses a scope conversion, AD's own error is surfaced.

type GroupScope

type GroupScope = adcore.GroupScope

type GroupSpec

type GroupSpec = adcore.GroupSpec

type Identity

type Identity = adcore.Identity

func ByDN

func ByDN(dn string) Identity

ByDN identifies an object by distinguished name.

func ByGUID

func ByGUID(guid string) Identity

ByGUID identifies an object by objectGUID. This is the canonical form: it survives rename and move, which DN and sAMAccountName do not.

func BySAM

func BySAM(sam string) Identity

BySAM identifies a security principal by sAMAccountName.

func BySID

func BySID(sid string) Identity

BySID identifies a security principal by SID.

type Inheritance added in v0.5.0

type Inheritance = adcore.Inheritance

type Kind

type Kind = adcore.Kind

func Classify

func Classify(exceptionType string, code int) Kind

Classify normalizes an AD exception into a Kind. It fails closed: an unrecognized (type, code) pair is KindUnknown and is never retried.

The Win32 code table is shared with the LDAP backend and lives in adcore; only the exception-name table below is PowerShell's, so only it stayed here. The code still wins wherever it is present, because the ActiveDirectory module builds the exception from it.

type Logger

type Logger interface {
	Debug(ctx context.Context, msg string, kv ...any)
}

Logger is the output port. A three-line adapter satisfies it from tflog, which is how the provider gets logging without this module importing anything from Terraform.

type Member added in v0.3.0

type Member = adcore.Member

type OU

type OU = adcore.OU

type OUClient

type OUClient struct {
	// contains filtered or unexported fields
}

OUClient is the organizational-unit sub-client.

func (*OUClient) Create

func (o *OUClient) Create(ctx context.Context, spec OUSpec) (*OU, error)

Create makes an organizational unit and returns the result of the same read Get performs, so an inconsistent result after apply is impossible by construction.

It may return a non-nil OU together with a non-nil error: that is the replication-timeout contract. The object exists and the wait did not complete, so the caller must persist the model and surface the error. Ignoring the model orphans the object.

func (*OUClient) Delete

func (o *OUClient) Delete(ctx context.Context, id Identity, opts DeleteOptions) error

Delete removes an organizational unit and returns nil only after a re-read confirms it is gone. A non-empty OU is never deleted recursively: the error names the child count. Remove-ADOrganizationalUnit -Recursive exists and is deliberately not reachable from this API.

func (*OUClient) Get

func (o *OUClient) Get(ctx context.Context, id Identity) (*OU, error)

Get reads one organizational unit.

func (*OUClient) Search added in v0.4.0

func (o *OUClient) Search(ctx context.Context, q Query) ([]OU, error)

Search returns every organizational unit under q.SearchBase matching q.Filter. It errors with KindTooManyResults rather than truncating: the script requests one row over the limit, and finding it means more exist.

func (*OUClient) Update

func (o *OUClient) Update(ctx context.Context, id Identity, spec OUSpec) (*OU, error)

Update folds the attribute write, the rename and the move into one round trip, in the order that keeps the DN valid. It never deletes and recreates, because that destroys the object's SID and with it every ACL referencing it.

Like Create, it may return a non-nil OU with a non-nil error on a replication timeout.

type OUSpec

type OUSpec = adcore.OUSpec

type OptTime

type OptTime = adcore.OptTime

func ClearTime

func ClearTime() OptTime

ClearTime clears accountExpires, which in AD means "never expires".

func SetTime

func SetTime(t time.Time) OptTime

SetTime writes accountExpires.

type Query added in v0.4.0

type Query = adcore.Query

type ReplicationConfig

type ReplicationConfig struct {
	Wait         bool
	Targets      []string // DC host names, or the single element "all"
	ForceSync    bool
	Timeout      time.Duration
	PollInterval time.Duration
}

ReplicationConfig governs the wait that follows a write. Replication is a property of domain topology, not of any single object, so it is configured once on the client.

type Result

type Result struct {
	Stdout   string
	Stderr   string
	ExitCode int
}

Result is the raw outcome of one pwsh invocation.

type RetryConfig

type RetryConfig = adcore.RetryConfig
type Right = adcore.Right

type SchemaClient added in v0.5.0

type SchemaClient struct {
	// contains filtered or unexported fields
}

SchemaClient resolves friendly schema names to GUIDs.

func (*SchemaClient) Resolve added in v0.5.0

func (s *SchemaClient) Resolve(ctx context.Context, refs []SchemaRef) (map[SchemaRef]string, error)

Resolve returns a GUID for each ref. Well-known names are answered from the in-process table; the rest go to the directory in one round trip. A name that resolves to nothing is returned as an empty string (the caller decides whether that is an error).

type SchemaRef added in v0.5.0

type SchemaRef = adcore.SchemaRef

type SchemaRefKind added in v0.5.0

type SchemaRefKind = adcore.SchemaRefKind

type SearchScope added in v0.4.0

type SearchScope = adcore.SearchScope

type Secret

type Secret = adcore.Secret

func NewSecret

func NewSecret(s string) Secret

NewSecret wraps a plaintext password.

type ServiceAccountClient added in v0.10.0

type ServiceAccountClient struct {
	// contains filtered or unexported fields
}

ServiceAccountClient is the group Managed Service Account (gMSA) sub-client.

func (*ServiceAccountClient) Create added in v0.10.0

func (s *ServiceAccountClient) Create(ctx context.Context, spec GMSASpec) (*GMSA, error)

Create makes a group Managed Service Account and returns the result of the same read Get performs. It may return a non-nil GMSA together with a non-nil error on a replication timeout; the caller must persist the model and surface the error.

func (*ServiceAccountClient) Delete added in v0.10.0

func (s *ServiceAccountClient) Delete(ctx context.Context, id Identity) error

Delete removes a group Managed Service Account and returns nil only after a re-read confirms it is gone.

func (*ServiceAccountClient) Get added in v0.10.0

func (s *ServiceAccountClient) Get(ctx context.Context, id Identity) (*GMSA, error)

Get reads one group Managed Service Account.

func (*ServiceAccountClient) Search added in v0.10.0

func (s *ServiceAccountClient) Search(ctx context.Context, q Query) ([]GMSA, error)

Search returns every group Managed Service Account under q.SearchBase matching q.Filter.

func (*ServiceAccountClient) Update added in v0.10.0

func (s *ServiceAccountClient) Update(ctx context.Context, id Identity, spec GMSASpec) (*GMSA, error)

Update folds the attribute write, the rename and the move into one round trip. ManagedPasswordIntervalInDays is never referenced here: it is create-only, since Set-ADServiceAccount has no such parameter.

type Transport

type Transport interface {
	Run(ctx context.Context, encodedCommand string, payload []byte) (Result, error)
	Close() error
}

Transport runs one PowerShell command on the jump box.

Implementations must invoke:

<pwsh> -NoProfile -NonInteractive -EncodedCommand <encodedCommand>

with payload written to the process's standard input and closed, and must return its stdout, stderr and exit code verbatim.

Run returns a non-nil error only when the process could not be run to completion — dial, authentication, channel exhaustion, context cancellation. A non-zero exit is reported through Result.ExitCode, never as an error: the distinction between "AD said no" and "we could not reach AD" is decided above this interface, not inside it. An implementation that can classify its own failure should return an *Error with the appropriate Kind (KindTransient for an exhausted channel, KindTransport for a dial or auth failure); any other error is treated as KindTransport.

type User

type User = adcore.User

type UserClient

type UserClient struct {
	// contains filtered or unexported fields
}

UserClient is the user sub-client.

func (*UserClient) Create

func (u *UserClient) Create(ctx context.Context, spec UserSpec) (*User, error)

Create makes a user account and returns the result of the same read Get performs. It may return a non-nil User together with a non-nil error on a replication timeout; the caller must persist the model and surface the error.

AD refuses to enable an account with no password satisfying domain policy, so Enabled: true without a Password fails with AD's own error rather than being papered over by silently creating a disabled account.

func (*UserClient) Delete

func (u *UserClient) Delete(ctx context.Context, id Identity) error

Delete removes a user and returns nil only after a re-read confirms it is gone.

func (*UserClient) Get

func (u *UserClient) Get(ctx context.Context, id Identity) (*User, error)

Get reads one user.

func (*UserClient) Search added in v0.4.0

func (u *UserClient) Search(ctx context.Context, q Query) ([]User, error)

Search returns every user under q.SearchBase matching q.Filter.

func (*UserClient) SetPassword

func (u *UserClient) SetPassword(ctx context.Context, id Identity, pw Secret) error

SetPassword resets the account's password through Set-ADAccountPassword -Reset. The error it returns never echoes the value.

func (*UserClient) Update

func (u *UserClient) Update(ctx context.Context, id Identity, spec UserSpec) (*User, error)

Update folds the attribute write, the rename and the move into one round trip. It never changes the password: -AccountPassword does not exist on Set-ADUser, so rotation goes through SetPassword.

type UserSpec

type UserSpec = adcore.UserSpec

Directories

Path Synopsis
cmd
adschema command
Command adschema exports an Active Directory schema catalog: every attribute's type and constraints, and every requested class's effective set of allowed attributes.
Command adschema exports an Active Directory schema catalog: every attribute's type and constraints, and every requested class's effective set of allowed attributes.
internal
adschema
Package adschema is the schema exporter's guts: one fetch over a transport, the inheritance closure that turns what was fetched into effective attribute sets, and the deterministic serialiser that writes the catalog.
Package adschema is the schema exporter's guts: one fetch over a transport, the inheritance closure that turns what was fetched into effective attribute sets, and the deterministic serialiser that writes the catalog.
adscript
Package adscript holds the constant PowerShell this library runs, the encoder that hands it to pwsh, and the builder for the attribute half of a Set-AD* payload.
Package adscript holds the constant PowerShell this library runs, the encoder that hands it to pwsh, and the builder for the attribute half of a Set-AD* payload.
oop
Package oop drives PowerShell's OutOfProcess PSRP framing over an arbitrary byte stream (an SSH subsystem channel to `pwsh -sshs`, or a local child process's stdio to `pwsh -SSHServerMode`), exposing an io.ReadWriter + MultiplexedTransport for github.com/smnsjas/go-psrpcore/runspace.
Package oop drives PowerShell's OutOfProcess PSRP framing over an arbitrary byte stream (an SSH subsystem channel to `pwsh -sshs`, or a local child process's stdio to `pwsh -SSHServerMode`), exposing an io.ReadWriter + MultiplexedTransport for github.com/smnsjas/go-psrpcore/runspace.
psrun
Package psrun is the shared runspace-executor core for the warm transports that drive a pwsh -sshs/-SSHServerMode server over the out-of-proc adapter.
Package psrun is the shared runspace-executor core for the warm transports that drive a pwsh -sshs/-SSHServerMode server over the out-of-proc adapter.
warm
Package warm is the transport-agnostic warm-runspace engine: a pool of persistent PSRP executors with an idle reaper and a one-shot, pre-execution-only retry.
Package warm is the transport-agnostic warm-runspace engine: a pool of persistent PSRP executors with an idle reaper and a one-shot, pre-execution-only retry.
transport
fake
Package fake provides a Transport double: it synthesizes result envelopes, injects AD exceptions, and records what was asked of it.
Package fake provides a Transport double: it synthesizes result envelopes, injects AD exceptions, and records what was asked of it.
local
Package local is the transport that runs PowerShell on the machine the caller itself runs on.
Package local is the transport that runs PowerShell on the machine the caller itself runs on.
localwarm
Package localwarm runs go-adpwsh operations in a persistent local PowerShell 7 runspace (pwsh -SSHServerMode) driven over the out-of-proc adapter, pooled by internal/warm.
Package localwarm runs go-adpwsh operations in a persistent local PowerShell 7 runspace (pwsh -SSHServerMode) driven over the out-of-proc adapter, pooled by internal/warm.
ssh
Package ssh is the transport that carries go-adpwsh to a Windows jump box.
Package ssh is the transport that carries go-adpwsh to a Windows jump box.
sshwarm
Package sshwarm runs go-adpwsh operations in a persistent pwsh -sshs runspace on a Windows jump box, reached over an SSH subsystem channel and driven by the out-of-proc adapter, pooled by internal/warm.
Package sshwarm runs go-adpwsh operations in a persistent pwsh -sshs runspace on a Windows jump box, reached over an SSH subsystem channel and driven by the out-of-proc adapter, pooled by internal/warm.
winrm
Package winrm runs go-adpwsh's PowerShell commands over PSRP/WinRM using github.com/smnsjas/go-psrp.
Package winrm runs go-adpwsh's PowerShell commands over PSRP/WinRM using github.com/smnsjas/go-psrp.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL