7be92da2e6
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>
367 lines
12 KiB
Go
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)
|
|
}
|
|
}
|