adpwsh

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 10 Imported by: 0

README

go-adpwsh

A Go library that drives Active Directory through the ActiveDirectory PowerShell module running on a Windows jump box, over SSH.

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.

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. Two ship: transport/ssh (real) 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, so a future WinRM or local-pwsh transport inherits every property above and no transport can reinterpret an AD refusal as a transport failure.
  • Catalog will be the schema seam. It is not in this release; adding Config.Catalog later is additive.

Jump-box requirements

  • A Windows member server (not a domain controller) reachable over SSH.
  • RSAT-AD-PowerShell installed.
  • PowerShell 7 (pwsh) on PATH. The scripts use ConvertFrom-Json -AsHashtable and the ?. null-conditional operator, neither of which exists in Windows PowerShell 5.1.
  • OpenSSH Server running.
  • TCP 9389 open from the jump box to the domain controller (the AD Web Services port the cmdlets use).

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. The module takes v1 only after the lab answers the ranged-read question for group membership and the acceptance suite passes against a real domain. Until then, minor versions may change the surface.

Membership (GetMembers/AddMembers/RemoveMembers/SetMembers), the Catalog interface, the generic Object sub-client, and tier-2 Attributes map[string]any are deliberately absent from this release.

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.

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

This section is empty.

Variables

View Source
var (
	ErrNotFound         error = kindSentinel{KindNotFound}
	ErrAlreadyExists    error = kindSentinel{KindAlreadyExists}
	ErrDenied           error = kindSentinel{KindDenied}
	ErrConstraint       error = kindSentinel{KindConstraint}
	ErrPassword         error = kindSentinel{KindPassword}
	ErrReferral         error = kindSentinel{KindReferral}
	ErrTransient        error = kindSentinel{KindTransient}
	ErrTransport        error = kindSentinel{KindTransport}
	ErrInvalidAttribute error = kindSentinel{KindInvalidAttribute}
	ErrSchema           error = kindSentinel{KindSchema}
	ErrReplication      error = kindSentinel{KindReplication}
)

Kind sentinels for errors.Is.

Functions

func Bool

func Bool(b bool) *bool

Bool is the pointer helper for optional booleans.

func String

func String(s string) *string

String is the pointer helper for the tri-state spec fields. Named for how it reads at the call site: Description: adpwsh.String("x").

Types

type Client

type Client struct {
	OU    *OUClient
	Group *GroupClient
	User  *UserClient
	// 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) Server

func (c *Client) Server() string

Server returns the pinned domain controller.

type Config

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

	// 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 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 DeleteOptions

type DeleteOptions struct {
	// Unprotect lifts ProtectedFromAccidentalDeletion before deleting. Without
	// it, deleting an OU created with AD's own default fails.
	Unprotect bool
}

DeleteOptions is taken only by OU.Delete. Making the unprotect step an explicit option keeps the destructive part visible at the call site.

type Error

type Error struct {
	Kind          Kind   // the only thing callers switch on
	Op            string // "User.Create"
	Identity      string // the identity acted on, in form:value notation
	ExceptionType string // verbatim, e.g. …ADIdentityNotFoundException
	Code          int    // Win32 code; decode via MS-ERREF
	ServerMessage string // the DC's own words, via IHasServerErrorMessage
	FQID          string // cmdlet-specific; diagnostics only
	Target        string
	Tombstoned    bool // set when an already-exists was traced to a deleted object
	Err           error
}

Error is the single error type this library returns. AD's raw detail is carried alongside the normalized Kind so a caller can render an exact message without switching on Microsoft's type names.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches the Kind sentinels, so errors.Is(err, ErrNotFound) works without exposing the sentinel's concrete type.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Group

type Group struct {
	GUID           string
	DN             string
	Name           string
	SamAccountName string
	Container      string
	Scope          GroupScope
	Category       GroupCategory
	Description    string
	ManagedBy      string
	SID            string
}

Group is a security or distribution group.

type GroupCategory

type GroupCategory string

GroupCategory distinguishes a security principal from a distribution list.

const (
	GroupCategorySecurity     GroupCategory = "security"
	GroupCategoryDistribution GroupCategory = "distribution"
)

type GroupClient

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

GroupClient is the group sub-client.

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) 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 string

GroupScope is the group's replication and membership scope.

const (
	GroupScopeGlobal      GroupScope = "global"
	GroupScopeDomainLocal GroupScope = "domainlocal"
	GroupScopeUniversal   GroupScope = "universal"
)

type GroupSpec

type GroupSpec struct {
	Name           string // the CN; a change means Rename-ADObject
	SamAccountName string // required; changes through Set-ADGroup
	Container      string // parent DN; a change means Move-ADObject
	Scope          GroupScope
	Category       GroupCategory // defaults to security
	Description    *string
	ManagedBy      *string
}

GroupSpec is the desired state of a group.

type Identity

type Identity interface {
	String() string
	// contains filtered or unexported methods
}

Identity is an AD -Identity argument. The interface is sealed by an unexported method: the only values that satisfy it come from the four constructors below, so there is no constructor taking an arbitrary string as an identity and no caller can hand the library a value that becomes script text.

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 Kind

type Kind int

Kind is the normalized condition a caller switches on. Adding a condition later adds a Kind, not a type: the public surface does not track Microsoft's exception list.

const (
	KindUnknown Kind = iota
	KindNotFound
	KindAlreadyExists
	KindDenied
	KindConstraint
	KindPassword
	KindReferral
	KindTransient
	KindTransport
	KindInvalidAttribute
	KindSchema
	// KindReplication means the write succeeded and the replication wait did
	// not complete. It is never retried, and the caller must persist the model
	// it was returned alongside.
	KindReplication
)

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.

func (Kind) String

func (k Kind) String() string

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 OU

type OU struct {
	GUID        string
	DN          string
	Name        string
	Container   string // derived from DN; never echoed by the script
	Description string
	Protected   bool
}

OU is an organizational unit as this library reads it back.

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) 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 struct {
	Name        string // the RDN; required on create, a change means Rename-ADObject
	Container   string // parent DN; required on create, a change means Move-ADObject
	Description *string
	Protected   *bool // ProtectedFromAccidentalDeletion
}

OUSpec is the desired state of an organizational unit. A nil pointer leaves the attribute alone; a pointer to "" clears it; a pointer to a value sets it.

type OptTime

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

OptTime is the three-state carrier a *time.Time cannot express: time.Time has no empty sentinel the way string does. The zero value leaves the attribute alone.

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.

func (OptTime) IsClear

func (o OptTime) IsClear() bool

IsClear reports whether the attribute should be cleared.

func (OptTime) IsSet

func (o OptTime) IsSet() bool

IsSet reports whether a value should be written.

func (OptTime) Value

func (o OptTime) Value() time.Time

Value is meaningful only when IsSet reports true.

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 struct {
	MaxAttempts    int
	InitialBackoff time.Duration
	MaxBackoff     time.Duration
	Jitter         float64 // fraction of the backoff, 0..1
}

RetryConfig is values, not code.

type Secret

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

Secret carries a password without letting it reach a log line, a state file, or a %v verb. Its plaintext is readable only inside this package, through reveal, which the payload builders call deliberately at the moment of serialization. This is the structural answer to the archived provider's credential leak: the guarantee is on the type, not on each call site.

func NewSecret

func NewSecret(s string) Secret

NewSecret wraps a plaintext password.

func (Secret) GoString

func (Secret) GoString() string

GoString makes %#v safe.

func (Secret) IsZero

func (s Secret) IsZero() bool

IsZero reports whether the secret was never set.

func (Secret) MarshalJSON

func (Secret) MarshalJSON() ([]byte, error)

MarshalJSON always fails. A Secret must be revealed deliberately by a payload builder; it must never be serialized by a struct walk into a log line or a state file.

func (Secret) String

func (Secret) String() string

String makes %v, %s and the print helpers safe.

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 struct {
	GUID                  string
	DN                    string
	Name                  string
	SamAccountName        string
	UserPrincipalName     string
	DisplayName           string
	GivenName             string
	Surname               string
	Description           string
	Container             string
	Enabled               bool
	SID                   string
	ChangePasswordAtLogon bool
	CanChangePassword     bool
	PasswordExpires       bool
	AccountExpiration     *time.Time // nil means the account never expires
}

User is a user account.

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) 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 struct {
	SamAccountName    string  // required on create
	Container         string  // required on create; a change means Move-ADObject
	Name              *string // the CN; defaults to SamAccountName on create; a change means Rename-ADObject
	UserPrincipalName *string
	DisplayName       *string
	GivenName         *string
	Surname           *string
	Description       *string

	Enabled               *bool
	Password              *Secret
	ChangePasswordAtLogon *bool
	CanChangePassword     *bool
	PasswordExpires       *bool
	AccountExpiration     OptTime
}

UserSpec is the desired state of a user account.

Directories

Path Synopsis
internal
addn
Package addn implements the slice of RFC 4514 (distinguished names) and RFC 4515 (search filters) this library needs: parsing and case-insensitive comparison of DNs, and escaping of filter assertion values.
Package addn implements the slice of RFC 4514 (distinguished names) and RFC 4515 (search filters) this library needs: parsing and case-insensitive comparison of DNs, and escaping of filter assertion values.
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.
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.
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.

Jump to

Keyboard shortcuts

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