1<?php
2
3/**
4 * This file is part of the FreeDSx LDAP package.
5 *
6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace FreeDSx\Ldap\Search;
13
14use Closure;
15use FreeDSx\Ldap\Control\Ad\DirSyncRequestControl;
16use FreeDSx\Ldap\Control\Ad\DirSyncResponseControl;
17use FreeDSx\Ldap\Control\Control;
18use FreeDSx\Ldap\Controls;
19use FreeDSx\Ldap\Entry\Entries;
20use FreeDSx\Ldap\Exception\OperationException;
21use FreeDSx\Ldap\Exception\RuntimeException;
22use FreeDSx\Ldap\LdapClient;
23use FreeDSx\Ldap\Operation\Request\SearchRequest;
24use FreeDSx\Ldap\Operation\Response\SearchResponse;
25use FreeDSx\Ldap\Protocol\LdapMessageResponse;
26use FreeDSx\Ldap\Search\Filter\FilterInterface;
27
28/**
29 * Provides a simple wrapper around DirSync for Active Directory.
30 *
31 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
32 */
33class DirSync
34{
35    /**
36     * @var DirSyncResponseControl|null
37     */
38    protected $lastResponse;
39
40    /**
41     * @var SearchRequest
42     */
43    protected $search;
44
45    /**
46     * @var string|null
47     */
48    protected $namingContext;
49
50    /**
51     * @var bool
52     */
53    protected $incrementalValues = true;
54
55    /**
56     * @var bool
57     */
58    protected $objectSecurity = false;
59
60    /**
61     * @var bool
62     */
63    protected $ancestorFirstOrder = false;
64
65    /**
66     * @var null|string
67     */
68    protected $defaultRootNc;
69
70    /**
71     * @var LdapClient
72     */
73    protected $client;
74
75    /**
76     * @var DirSyncRequestControl
77     */
78    protected $dirSyncRequest;
79
80    /**
81     * @param LdapClient $client
82     * @param string|null $namingContext
83     * @param FilterInterface|null $filter
84     * @param mixed ...$attributes
85     */
86    public function __construct(LdapClient $client, ?string $namingContext = null, ?FilterInterface $filter = null, ...$attributes)
87    {
88        $this->client = $client;
89        $this->namingContext = $namingContext;
90        $this->dirSyncRequest = Controls::dirSync();
91        $this->search = (new SearchRequest($filter ?? Filters::present('objectClass'), ...$attributes));
92    }
93
94    /**
95     * A convenience method to easily watch for changes with an anonymous function. The anonymous function will be passed
96     * two arguments:
97     *
98     *     - The Entries object containing the changes.
99     *     - A boolean value indicating whether or not the entries are part of the initial sync (the initial sync returns
100     *       all entries matching the filter).
101     *
102     * An optional second argument then determines how many seconds to wait between checking for changes.
103     *
104     * @param \Closure $handler An anonymous function to pass results to.
105     * @param int $checkInterval How often to check for changes (in seconds).
106     * @throws OperationException
107     */
108    public function watch(Closure $handler, int $checkInterval = 10): void
109    {
110        $handler($this->getChanges(), true);
111        while ($this->hasChanges()) {
112            $handler($this->getChanges(), true);
113        }
114
115        while (true) {
116            sleep($checkInterval);
117            $entries = $this->getChanges();
118            if ($entries->count() === 0) {
119                continue;
120            }
121            $handler($entries, false);
122            while ($this->hasChanges()) {
123                $handler($this->getChanges(), false);
124            }
125        }
126    }
127
128    /**
129     * Check whether or not there are more changes to receive.
130     *
131     * @return bool
132     */
133    public function hasChanges(): bool
134    {
135        if ($this->lastResponse === null) {
136            return false;
137        }
138
139        return $this->lastResponse->hasMoreResults();
140    }
141
142    /**
143     * Get the changes as entries. This may be empty if there are no changes since the last query. This should be
144     * followed with a hasChanges() call to determine if more changes are still available.
145     *
146     * @return Entries
147     * @throws OperationException
148     */
149    public function getChanges(): Entries
150    {
151        /** @var LdapMessageResponse $response */
152        $response = $this->client->send($this->getSearchRequest(), $this->getDirSyncControl());
153        $lastResponse = $response->controls()->get(Control::OID_DIR_SYNC);
154        if ($lastResponse === null || !$lastResponse instanceof DirSyncResponseControl) {
155            throw new RuntimeException('Expected a DirSync control in the response, but none was received.');
156        }
157        $this->lastResponse = $lastResponse;
158        $this->dirSyncRequest->setCookie($this->lastResponse->getCookie());
159        /** @var SearchResponse $searchResponse */
160        $searchResponse = $response->getResponse();
161
162        return $searchResponse->getEntries();
163    }
164
165    /**
166     * The attributes to return from the DirSync search.
167     *
168     * @param mixed ...$attributes
169     * @return DirSync
170     */
171    public function selectAttributes(...$attributes)
172    {
173        $this->search->select(...$attributes);
174
175        return $this;
176    }
177
178    /**
179     * A specific DirSync cookie to use. For example, this could be a cookie from a previous DirSync request, assuming
180     * the server still thinks it's valid.
181     *
182     * @param string $cookie
183     * @return $this
184     */
185    public function useCookie(string $cookie)
186    {
187        $this->dirSyncRequest->setCookie($cookie);
188
189        return $this;
190    }
191
192    /**
193     * The naming context to run the DirSync against. This MUST be a root naming context.
194     *
195     * @param string|null $namingContext
196     * @return $this
197     */
198    public function useNamingContext(?string $namingContext)
199    {
200        $this->namingContext = $namingContext;
201
202        return $this;
203    }
204
205    /**
206     * The LDAP filter to limit the results to.
207     *
208     * @param FilterInterface $filter
209     * @return $this
210     */
211    public function useFilter(FilterInterface $filter)
212    {
213        $this->search->setFilter($filter);
214
215        return $this;
216    }
217
218    /**
219     * Whether or not to return only incremental changes on a multivalued attribute that has changed.
220     *
221     * @param bool $incrementalValues
222     * @return $this
223     */
224    public function useIncrementalValues(bool $incrementalValues = true)
225    {
226        $this->incrementalValues = $incrementalValues;
227
228        return $this;
229    }
230
231    /**
232     * Whether or not to only retrieve objects and attributes that are accessible to the client.
233     *
234     * @param bool $objectSecurity
235     * @return $this
236     */
237    public function useObjectSecurity(bool $objectSecurity = true)
238    {
239        $this->objectSecurity = $objectSecurity;
240
241        return $this;
242    }
243
244    /**
245     * Whether or not the server should return parent objects before child objects.
246     *
247     * @param bool $ancestorFirstOrder
248     * @return $this
249     */
250    public function useAncestorFirstOrder(bool $ancestorFirstOrder = true)
251    {
252        $this->ancestorFirstOrder = $ancestorFirstOrder;
253
254        return $this;
255    }
256
257    /**
258     * Get the cookie currently in use.
259     *
260     * @return string
261     */
262    public function getCookie(): string
263    {
264        return $this->dirSyncRequest->getCookie();
265    }
266
267    /**
268     * @return SearchRequest
269     * @throws OperationException
270     */
271    protected function getSearchRequest(): SearchRequest
272    {
273        $this->search->base($this->namingContext ?? $this->getDefaultRootNc());
274
275        return $this->search;
276    }
277
278    /**
279     * @return DirSyncRequestControl
280     */
281    protected function getDirSyncControl(): DirSyncRequestControl
282    {
283        $flags = 0;
284        if ($this->incrementalValues) {
285            $flags |= DirSyncRequestControl::FLAG_INCREMENTAL_VALUES;
286        }
287        if ($this->ancestorFirstOrder) {
288            $flags |= DirSyncRequestControl::FLAG_ANCESTORS_FIRST_ORDER;
289        }
290        if ($this->objectSecurity) {
291            $flags |= DirSyncRequestControl::FLAG_OBJECT_SECURITY;
292        }
293        $this->dirSyncRequest->setFlags($flags);
294
295        return $this->dirSyncRequest;
296    }
297
298    /**
299     * @return string
300     * @throws OperationException
301     */
302    protected function getDefaultRootNc(): string
303    {
304        if ($this->defaultRootNc === null) {
305            $this->defaultRootNc = (string) $this->client->readOrFail('', ['defaultNamingContext'])->get('defaultNamingContext');
306        }
307        if ($this->defaultRootNc === '') {
308            throw new RuntimeException('Unable to determine the root naming context automatically.');
309        }
310
311        return $this->defaultRootNc;
312    }
313}
314