Files
Alphaeus Mote 7be92da2e6 feat(rules): canonical->DN targets and idempotent OU-path creation
Add CanonicalToOUDN/NormalizeOUTarget so an OU target can be given as a canonical
path (domain.com/OU/OU) or a DN, and Client.EnsureOUPath which idempotently
creates every OU down the path (parents before children). MoveToOu and group
creation now normalize the target and ensure the full OU path instead of only
the leaf. Unit-tested (conversion, escaped split) and verified against the test
AD: a canonical nested target creates each OU and moves the object; re-running is
a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:47:26 -04:00

367 lines
12 KiB
Go

//go:build adtest
// Package adtest holds opt-in integration tests that exercise the rule engine
// against a real Active Directory. They are excluded from normal builds by the
// `adtest` build tag and additionally skip unless ORCHESTRAD_AD_TEST_HOST is set.
//
// Run against a test AD (plain LDAP 389), e.g.:
//
// ORCHESTRAD_AD_TEST_HOST=172.16.32.65 \
// ORCHESTRAD_AD_TEST_BIND='OrchestrAD@gracesolutions.lab' \
// ORCHESTRAD_AD_TEST_PASSWORD='OrchestrAD' \
// ORCHESTRAD_AD_TEST_BASEDN='DC=gracesolutions,DC=lab' \
// go test -tags adtest ./internal/adtest/ -v
//
// Each test creates a uniquely named OU under the base DN, does its work there,
// and tree-deletes it on cleanup, so nothing is left behind.
package adtest
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
appldap "github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
ldapv3 "github.com/go-ldap/ldap/v3"
)
// treeDeleteOID is the AD control for recursive (subtree) delete.
const treeDeleteOID = "1.2.840.113556.1.4.805"
// securityGlobalGroupType is the groupType flag for a global security group.
const securityGlobalGroupType = "-2147483646"
type adEnv struct {
host, bind, password, baseDN string
port int
}
func loadEnv(t *testing.T) adEnv {
t.Helper()
host := os.Getenv("ORCHESTRAD_AD_TEST_HOST")
if host == "" {
t.Skip("ORCHESTRAD_AD_TEST_HOST not set; skipping AD integration tests")
}
port := 389
if p := os.Getenv("ORCHESTRAD_AD_TEST_PORT"); p != "" {
port, _ = strconv.Atoi(p)
}
return adEnv{
host: host,
port: port,
bind: os.Getenv("ORCHESTRAD_AD_TEST_BIND"),
password: os.Getenv("ORCHESTRAD_AD_TEST_PASSWORD"),
baseDN: os.Getenv("ORCHESTRAD_AD_TEST_BASEDN"),
}
}
// rawConn opens a plain go-ldap connection for fixture setup/teardown/verify.
func (e adEnv) rawConn(t *testing.T) *ldapv3.Conn {
t.Helper()
conn, err := ldapv3.DialURL(fmt.Sprintf("ldap://%s:%d", e.host, e.port))
if err != nil {
t.Fatalf("dial: %v", err)
}
if err := conn.Bind(e.bind, e.password); err != nil {
conn.Close()
t.Fatalf("bind: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
// appClient returns the application's LDAP client (used by the engine).
func (e adEnv) appClient(t *testing.T) *appldap.Client {
t.Helper()
c := appldap.NewClient(&appldap.Config{
Hosts: []string{e.host},
Port: e.port,
UseTLS: false,
BindDN: e.bind,
BindPassword: e.password,
RootDN: e.baseDN,
Timeout: 15 * time.Second,
})
if err := c.Connect(); err != nil {
t.Fatalf("app client connect: %v", err)
}
if err := c.Bind(); err != nil {
t.Fatalf("app client bind: %v", err)
}
t.Cleanup(c.Close)
return c
}
// makeOU creates a unique test OU and registers a recursive-delete cleanup.
func (e adEnv) makeOU(t *testing.T, conn *ldapv3.Conn, label string) string {
t.Helper()
name := fmt.Sprintf("OrchestrAD-%s-%d", label, time.Now().UnixNano())
dn := fmt.Sprintf("OU=%s,%s", name, e.baseDN)
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"organizationalUnit"})
if err := conn.Add(add); err != nil {
t.Fatalf("create OU %s: %v", dn, err)
}
t.Cleanup(func() {
del := ldapv3.NewDelRequest(dn, []ldapv3.Control{ldapv3.NewControlString(treeDeleteOID, true, "")})
if err := conn.Del(del); err != nil {
t.Logf("cleanup tree-delete %s: %v", dn, err)
}
})
return dn
}
func addUser(t *testing.T, conn *ldapv3.Conn, dn string, attrs map[string][]string) {
t.Helper()
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"})
// Disabled account (no password set; plain LDAP cannot set unicodePwd).
add.Attribute("userAccountControl", []string{"514"})
for k, v := range attrs {
add.Attribute(k, v)
}
if err := conn.Add(add); err != nil {
t.Fatalf("create user %s: %v", dn, err)
}
}
func addGroup(t *testing.T, conn *ldapv3.Conn, dn, sam string) {
t.Helper()
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"group"})
add.Attribute("sAMAccountName", []string{sam})
add.Attribute("groupType", []string{securityGlobalGroupType})
if err := conn.Add(add); err != nil {
t.Fatalf("create group %s: %v", dn, err)
}
}
func groupMembers(t *testing.T, conn *ldapv3.Conn, groupDN string) []string {
t.Helper()
res, err := conn.Search(ldapv3.NewSearchRequest(
groupDN, ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{"member"}, nil,
))
if err != nil {
t.Fatalf("read group members %s: %v", groupDN, err)
}
if len(res.Entries) == 0 {
return nil
}
return res.Entries[0].GetAttributeValues("member")
}
func exists(t *testing.T, conn *ldapv3.Conn, dn string) bool {
t.Helper()
res, err := conn.Search(ldapv3.NewSearchRequest(
dn, ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{"distinguishedName"}, nil,
))
if err != nil {
return false
}
return len(res.Entries) == 1
}
func containsDN(list []string, dn string) bool {
for _, e := range list {
if strings.EqualFold(e, dn) {
return true
}
}
return false
}
func conn(baseDN string) *models.ADConnection {
return &models.ADConnection{RootDN: baseDN, DefaultSearchScope: "Subtree"}
}
func actionJSON(t *testing.T, cfg models.ActionConfig) string {
t.Helper()
b, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal action config: %v", err)
}
return string(b)
}
// ruleUsersWhere builds a User rule with one equals-condition and the given actions.
func ruleUsersWhere(baseDN, attr, value string, actions ...models.RuleAction) *models.Rule {
v := value
return &models.Rule{
ID: "test-rule",
Name: "Integration Test Rule",
ObjectType: string(types.ObjectTypeUser),
BaseDNOverride: &baseDN,
GroupJoinOperator: string(types.JoinOperatorAND),
ExecutionMode: string(types.ExecutionModeApply),
ConditionGroups: []models.RuleConditionGroup{{
IsEnabled: true,
JoinOperator: string(types.JoinOperatorAND),
Conditions: []models.RuleCondition{{
IsEnabled: true,
AttributeName: attr,
Operator: string(types.OperatorEquals),
ComparisonValue: &v,
}},
}},
Actions: actions,
}
}
func newEngine() *engine.Engine {
return engine.NewEngine(logging.Default())
}
// TestAddUsersToGroupByCondition: users with department=Engineering are added to
// a target group; a Sales user is not.
func TestAddUsersToGroupByCondition(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "addgroup")
alice := "CN=oad-alice," + ou
bob := "CN=oad-bob," + ou
carol := "CN=oad-carol," + ou
addUser(t, raw, alice, map[string][]string{"sAMAccountName": {"oad-alice"}, "department": {"Engineering"}})
addUser(t, raw, bob, map[string][]string{"sAMAccountName": {"oad-bob"}, "department": {"Sales"}})
addUser(t, raw, carol, map[string][]string{"sAMAccountName": {"oad-carol"}, "department": {"Engineering"}})
group := "CN=oad-engineers," + ou
addGroup(t, raw, group, "oad-engineers")
rule := ruleUsersWhere(ou, "department", "Engineering", models.RuleAction{
ID: "a1", ActionType: string(types.ActionAddToGroup), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetGroupDN: group}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; errors: %+v", res.Status, res.Errors)
}
if res.ObjectsMatched != 2 {
t.Errorf("ObjectsMatched = %d, want 2 (alice, carol)", res.ObjectsMatched)
}
members := groupMembers(t, raw, group)
if !containsDN(members, alice) || !containsDN(members, carol) {
t.Errorf("group missing expected members; got %v", members)
}
if containsDN(members, bob) {
t.Errorf("Sales user bob should not be a member; got %v", members)
}
}
// TestAddToGroupCreatesMissingGroup: AddToGroup with createIfMissing provisions
// the group before adding the member.
func TestAddToGroupCreatesMissingGroup(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "creategroup")
dave := "CN=oad-dave," + ou
addUser(t, raw, dave, map[string][]string{"sAMAccountName": {"oad-dave"}, "department": {"IT"}})
group := "CN=oad-it-created," + ou // does not exist yet
if exists(t, raw, group) {
t.Fatalf("precondition: group should not exist")
}
rule := ruleUsersWhere(ou, "department", "IT", models.RuleAction{
ID: "a1", ActionType: string(types.ActionAddToGroup), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{
TargetGroupDN: group, CreateIfMissing: true, GroupType: "Security", GroupScope: "Global",
}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
if !exists(t, raw, group) {
t.Fatalf("group was not created")
}
if members := groupMembers(t, raw, group); !containsDN(members, dave) {
t.Errorf("created group missing member dave; got %v", members)
}
}
// TestMoveToOU: a matching user is moved into a target OU (created on demand).
func TestMoveToOU(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "move")
erin := "CN=oad-erin," + ou
addUser(t, raw, erin, map[string][]string{"sAMAccountName": {"oad-erin"}, "department": {"Relocate"}})
targetOU := "OU=oad-moved," + ou
rule := ruleUsersWhere(ou, "department", "Relocate", models.RuleAction{
ID: "a1", ActionType: string(types.ActionMoveToOu), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetOU: targetOU, CreateOUIfMissing: true}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
movedDN := "CN=oad-erin," + targetOU
if !exists(t, raw, movedDN) {
t.Errorf("user was not moved to %s", movedDN)
}
if exists(t, raw, erin) {
t.Errorf("user still present at old DN %s", erin)
}
}
// TestMoveToNestedOUByCanonical: a canonical target path (domain/OU/OU/OU) is
// converted to a DN and every missing OU is created idempotently, then the
// object is moved into the leaf OU. Re-running the rule is a no-op.
func TestMoveToNestedOUByCanonical(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "nested")
frank := "CN=oad-frank," + ou
addUser(t, raw, frank, map[string][]string{"sAMAccountName": {"oad-frank"}, "department": {"Nest"}})
// Canonical target two levels below the (existing) test OU.
ouCanonical := appldap.CanonicalName(ou, "")
targetCanonical := ouCanonical + "/Level1/Level2"
rule := ruleUsersWhere(ou, "department", "Nest", models.RuleAction{
ID: "a1", ActionType: string(types.ActionMoveToOu), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetOU: targetCanonical, CreateOUIfMissing: true}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
level1 := "OU=Level1," + ou
level2 := "OU=Level2," + level1
if !exists(t, raw, level1) || !exists(t, raw, level2) {
t.Fatalf("nested OU path not created (level1=%v level2=%v)", exists(t, raw, level1), exists(t, raw, level2))
}
movedDN := "CN=oad-frank," + level2
if !exists(t, raw, movedDN) {
t.Errorf("user not moved to %s", movedDN)
}
// Idempotent: running again does not error (OUs exist, user already there).
res2 := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res2.Status != types.RunStatusCompleted {
t.Errorf("re-run status = %s, want Completed; %+v", res2.Status, res2.Errors)
}
}