Business rules¶
clsADUserServices enforces 34 business rules, all of service origin — none is inherited from a base class or restated from an embedded or dependency library, because the repository contains no <ProjectReference> and no embedded library. The rules span the network-origin authorization gate (BR-01–BR-05), domain and container selection (BR-07–BR-09), the shape of each of the 19 published operations (BR-10–BR-32), and the shared directory-lookup failure behavior (BR-33–BR-34). Statements, triggers, enforcement points and outcomes below are taken verbatim from the analysis catalog.
Navigation index¶
| Rule ID | Title | Reached from |
|---|---|---|
| BR-01 | Mutating and password operations require an approved caller | 8 gated operations |
| BR-02 | A local request is always approved | CheckAccess |
| BR-03 | An allow-listed source address is approved | CheckAccess |
| BR-04 | The caller's source address prefers the forwarded-for header | CheckAccess |
| BR-05 | A denied request is reported as a normal response | 8 gated operations |
| BR-06 | Read-only directory queries are not access-controlled | 11 ungated operations |
| BR-07 | The directory domain is selected by the caller | every operation except HelloWorld/CheckAccess |
| BR-08 | Mutating operations always target the external domain | 8 gated operations |
| BR-09 | A new account is created in the CN=Users container | AddUser |
| BR-10 | An empty password is replaced with a hard-coded default | AddUser |
| BR-11 | A new account is provisioned enabled with fixed identity fields | AddUser |
| BR-12 | An existing account is not re-created | AddUser |
| BR-13 | An optional attribute is written only when supplied | AddUser, ChangeUserAccount |
| BR-14 | An unlock request for an unknown user is reported as such | UnlockAccount |
| BR-15 | An account is unlocked only when locked and the result is re-verified | UnlockAccount |
| BR-16 | A group-membership addition requires both user and group to exist | AddUserToGroup |
| BR-17 | Adding an already-a-member user is a distinct outcome | AddUserToGroup |
| BR-18 | Deleting a non-existent user is a successful no-op | DeleteUser |
| BR-19 | Group removal drops to the underlying DirectoryEntry | DeleteUserFromGroup |
| BR-20 | A failed group removal is reported as success | DeleteUserFromGroup |
| BR-21 | A user exists when a principal resolves | DoesUserExist |
| BR-22 | A user has a resource by exact transitive group-name match | DoesUserHaveResource |
| BR-23 | An account change updates only non-empty fields | ChangeUserAccount |
| BR-24 | Directory attribute changes commit only when modified is raised | ChangeUserAccount |
| BR-25 | A password change is applied via SetPassword and committed | ChangeUserPassword, ChangeUserPasswordOrGetException |
| BR-26 | Two password-change variants differ only in error strategy | ChangeUserPassword, ChangeUserPasswordOrGetException |
| BR-27 | A directory attribute query returns the value or UserDoesNotExist | GetUserEmail, GetUserFAX, GetUserPrinter |
| BR-28 | The group list includes nested membership | GetUserGroups |
| BR-29 | A resource is a Resource-named group filtered by caller-supplied environment | GetUserResources |
| BR-30 | Last logon is derived from the FILETIME attribute | GetUserLastLogon |
| BR-31 | The printer attribute is read from the internal domain only | GetUserPrinter |
| BR-32 | Group membership for IsUserInGroup is direct only | IsUserInGroup |
| BR-33 | A group is resolved by SamAccountName, taking the first match | AddUserToGroup, DeleteUserFromGroup, DoesUserHaveResource, IsUserInGroup |
| BR-34 | A directory lookup failure is reported as not found | every operation except HelloWorld/CheckAccess |
BR-01 Mutating and password operations require an approved caller¶
| Field | Value |
|---|---|
| Statement | A mutating or password-changing operation may only proceed for an approved caller |
| Trigger | Entry to any of the eight gated operations |
| Enforcement | If (Not CheckAccess().HasAccess) Then Return AccessDenied() — src\ADUserServices\clsADUserServices.asmx.vb lines 105, 202, 234, 273, 302, 395, 474, 495 |
| Origin | service |
| Reached from | AddUser, UnlockAccount, AddUserToGroup, DeleteUser, DeleteUserFromGroup, ChangeUserAccount, ChangeUserPassword, ChangeUserPasswordOrGetException |
| Outcome | On denial, an ADServiceResponse with Response = Failure, Message = "Access Denied", returned as a normal (non-fault) SOAP response |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:105,202,234,273,302,395,474,495 |
BR-02 A local request is always approved¶
| Field | Value |
|---|---|
| Statement | A request originating on the server itself is approved |
| Trigger | CheckAccess evaluation |
| Enforcement | If (HttpContext.Current.Request.IsLocal) — lines 762–765 |
| Origin | service |
| Reached from | CheckAccess and all eight gated operations |
| Outcome | HasAccess = True, Local = True in HasAccessResponse |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:762-765 |
BR-03 An allow-listed source address is approved¶
| Field | Value |
|---|---|
| Statement | A request whose observed source address exactly matches an entry in the configured allow-list is approved |
| Trigger | CheckAccess evaluation |
| Enforcement | For Each ip In GetAllowedIPs(), comparing each entry to GetIncomingIp(); on a match, sets response.HasAccess = True and response.EnvironmentApi = True — lines 767–772 |
| Origin | service |
| Reached from | CheckAccess and all eight gated operations |
| Outcome | HasAccess = True, EnvironmentApi = True |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:767-772 |
BR-04 The caller's source address prefers the forwarded-for header¶
| Field | Value |
|---|---|
| Statement | The caller's source address is taken from HTTP_X_FORWARDED_FOR, falling back to REMOTE_ADDR only when that header is empty |
| Trigger | Every CheckAccess evaluation |
| Enforcement | GetIncomingIp — lines 798–806 |
| Origin | service |
| Reached from | CheckAccess and all eight gated operations |
| Outcome | The chosen value is compared in BR-03 and returned in HasAccessResponse.RemoteAddress |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:798-806 |
BR-05 A denied request is reported as a normal response¶
| Field | Value |
|---|---|
| Statement | A denied request is reported as a normal response carrying a failure code, never as a SOAP fault |
| Trigger | Denial under BR-01 |
| Enforcement | AccessDenied — lines 781–789 |
| Origin | service |
| Reached from | the eight gated operations |
| Outcome | ADServiceResponse{Response=Failure, Message="Access Denied"} |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:781-789 |
BR-06 Read-only directory queries are not access-controlled¶
| Field | Value |
|---|---|
| Statement | Read-only directory queries are not access-controlled |
| Trigger | Entry to any of the eleven ungated operations |
| Enforcement | Absence of any check in the method bodies |
| Origin | service |
| Reached from | HelloWorld, DoesUserExist, DoesUserHaveResource, GetUserEmail, GetUserGroups, GetUserResources, GetUserFAX, GetUserLastLogon, GetUserPrinter, IsUserInGroup, CheckAccess |
| Outcome | The operation executes for any caller that can reach the endpoint |
| Source | src\ADUserServices\clsADUserServices.asmx.vb |
BR-07 The directory domain is selected by the caller¶
| Field | Value |
|---|---|
| Statement | The directory domain is selected by the caller's inExternalDomain flag: external.saif.com when true, corp.saif.com when false |
| Trigger | Every directory context creation |
| Enforcement | GetContext — lines 823–827, using constants DOMAIN_EXT/DOMAIN_INT (lines 47–48) |
| Origin | service |
| Reached from | every operation except HelloWorld and CheckAccess |
| Outcome | Determines which forest answers the query; invisible in the response |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:823-827 |
BR-08 Mutating operations always target the external domain¶
| Field | Value |
|---|---|
| Statement | Every mutating operation targets the external domain unconditionally; the internal domain is never written |
| Trigger | Entry to any mutating operation |
| Enforcement | Literal True passed to GetContext/GetUserPrincipal/GetGroupPrincipal, with the repeated authored comment 'Assume external domain - NetSec will NOT use this |
| Origin | service |
| Reached from | the eight gated operations |
| Outcome | Internal-domain accounts cannot be created, modified, deleted, unlocked, re-passworded or re-grouped through this service |
| Source | src\ADUserServices\clsADUserServices.asmx.vb |
BR-09 A new account is created in the CN=Users container¶
| Field | Value |
|---|---|
| Statement | A new account is created in the CN=Users container of the external domain |
| Trigger | AddUser, after the existence test |
| Enforcement | Dim myContainer As String = "CN=Users," & CONTAINER_EXT (line 109) passed to GetContext (line 133) |
| Origin | service |
| Reached from | AddUser |
| Outcome | The account's distinguished name is fixed to that container; no caller input can change it |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:109,133 |
BR-10 An empty password is replaced with a hard-coded default¶
| Field | Value |
|---|---|
| Statement | When the caller supplies an empty password, a hard-coded default password is used instead |
| Trigger | AddUser, before principal population |
| Enforcement | If inPassword = String.Empty Then inPassword = <literal> (within lines 118–122) |
| Origin | service |
| Reached from | AddUser |
| Outcome | The account is created with a known shared credential (SEC-05, CRITICAL). The literal is not reproduced in documentation |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:118-122 |
BR-11 A new account is provisioned enabled with fixed identity fields¶
| Field | Value |
|---|---|
| Statement | A new account is provisioned enabled, with DisplayName and SamAccountName both set to the supplied user id, and ScriptPath set to a single space |
| Trigger | AddUser, principal population |
| Enforcement | With userPrin, setting .GivenName, .Surname, .DisplayName = inUserId, .Name, .SamAccountName = inUserId, .SetPassword(inPassword), .ScriptPath = " ", .Description, and .Enabled = True (lines 136–152) |
| Origin | service |
| Reached from | AddUser |
| Outcome | The created account is immediately usable; Name is taken from the separate inName parameter |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:136-152 |
BR-12 An existing account is not re-created¶
| Field | Value |
|---|---|
| Statement | An account that already exists is not re-created |
| Trigger | AddUser, existence test |
| Enforcement | userPrin = GetUserPrincipal(inUserId, True) then If Not userPrin Is Nothing (lines 127–131) |
| Origin | service |
| Reached from | AddUser |
| Outcome | Response = Failure with an empty Message. The UserExists code is assigned and then immediately overwritten, so it never reaches the wire (see Tech debt) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:127-131 |
BR-13 An optional attribute is written only when supplied¶
| Field | Value |
|---|---|
| Statement | An optional directory attribute is written only when the corresponding input is non-empty |
| Trigger | AddUser and ChangeUserAccount, attribute stage |
| Enforcement | If inX <> String.Empty Then de.Properties("<attribute>").Value = inX, applied per attribute (company, telephoneNumber, mail, info, title, facsimileTelephoneNumber, division) — AddUser lines 158–179; ChangeUserAccount lines 435–457 |
| Origin | service |
| Reached from | AddUser, ChangeUserAccount |
| Outcome | An empty input leaves the existing attribute untouched; there is no way to clear an attribute through this service |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:158-179,435-457 |
BR-14 An unlock request for an unknown user is reported as such¶
| Field | Value |
|---|---|
| Statement | An unlock request for an unknown user is reported as such |
| Trigger | UnlockAccount |
| Enforcement | If (userPrin Is Nothing) — lines 206–208 |
| Origin | service |
| Reached from | UnlockAccount |
| Outcome | Response = UserDoesNotExist, Message naming the user id |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:206-208 |
BR-15 An account is unlocked only when locked and the result is re-verified¶
| Field | Value |
|---|---|
| Statement | An account is unlocked only when it is actually locked out, and the result is re-verified before reporting |
| Trigger | UnlockAccount |
| Enforcement | If (userPrin.IsAccountLockedOut()) calls userPrin.UnlockAccount(), then re-checks with IIf(userPrin.IsAccountLockedOut(), "The account was not unlocked", "The account was unlocked!") — lines 210–217 |
| Origin | service |
| Reached from | UnlockAccount |
| Outcome | Response = Success in every branch; the distinction ("was unlocked" / "was not unlocked" / "was not locked") is carried only in Message free text |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:210-217 |
BR-16 A group-membership addition requires both user and group to exist¶
| Field | Value |
|---|---|
| Statement | A group-membership addition requires both the user and the group to exist |
| Trigger | AddUserToGroup |
| Enforcement | Lines 241–250 |
| Origin | service |
| Reached from | AddUserToGroup |
| Outcome | Response = UserDoesNotExist or GroupDoesNotExist; no membership change is attempted |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:241-250 |
BR-17 Adding an already-a-member user is a distinct outcome¶
| Field | Value |
|---|---|
| Statement | An attempt to add a user who is already a member is reported as a distinct outcome rather than a failure |
| Trigger | AddUserToGroup, exception path |
| Enforcement | If ex.Message.Contains("already exists") — lines 256–258 |
| Origin | service |
| Reached from | AddUserToGroup |
| Outcome | Response = UserAlreadyInGroup. Detection depends on an English substring of the directory exception message* |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:256-258 |
BR-18 Deleting a non-existent user is a successful no-op¶
| Field | Value |
|---|---|
| Statement | Deleting a user who does not exist is a successful no-op |
| Trigger | DeleteUser |
| Enforcement | If userPrin IsNot Nothing Then wraps userPrin.Delete() in a Try/Catch, with no Else branch — lines 283–293 |
| Origin | service |
| Reached from | DeleteUser |
| Outcome | Response = Success, empty Message |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:283-293 |
BR-19 Group removal drops to the underlying DirectoryEntry¶
| Field | Value |
|---|---|
| Statement | Group removal is performed against the underlying DirectoryEntry rather than the GroupPrincipal, because the managed removal fails on large groups |
| Trigger | DeleteUserFromGroup, removal stage |
| Enforcement | deGroup.Invoke("Remove", New Object() {deUser.Path}) + deGroup.CommitChanges() — lines 326–330, with the authored comment at line 325 |
| Origin | service |
| Reached from | DeleteUserFromGroup |
| Outcome | The member is removed by distinguished-name path; late-bound invocation (see Tech debt) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:325-330 |
BR-20 A failed group removal is reported as success¶
| Field | Value |
|---|---|
| Statement | A failure to remove a user from a group is treated as "the user was probably not in the group" and reported as success |
| Trigger | DeleteUserFromGroup, exception path |
| Enforcement | Empty Catch with the authored comment 'gobble - user is pribably not in the group — lines 331–333 |
| Origin | service |
| Reached from | DeleteUserFromGroup |
| Outcome | Response = Success. A genuine directory failure is indistinguishable from a completed removal (see Tech debt — TD-20) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:331-333 |
BR-21 A user exists when a principal resolves¶
| Field | Value |
|---|---|
| Statement | A user "exists" when a principal can be resolved for the supplied id in the selected domain |
| Trigger | DoesUserExist |
| Enforcement | Lines 347–352 |
| Origin | service |
| Reached from | DoesUserExist |
| Outcome | True or False. A directory failure also yields False (see Tech debt — TD-22) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:347-352 |
BR-22 A user has a resource by exact transitive group-name match¶
| Field | Value |
|---|---|
| Statement | A user "has" a resource when one of the user's authorization groups has a name exactly equal to the requested resource name |
| Trigger | DoesUserHaveResource |
| Enforcement | For Each grp As GroupPrincipal In userPrin.GetAuthorizationGroups, checking If grp.Name = inResourceName — lines 372–377 |
| Origin | service |
| Reached from | DoesUserHaveResource |
| Outcome | Message = the literal "True" or "False"; Response = UserDoesNotExist when no principal resolves. Matching is on Name, not SamAccountName, and is exact |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:372-377 |
BR-23 An account change updates only non-empty fields¶
| Field | Value |
|---|---|
| Statement | An account change updates only the fields whose inputs are non-empty, and principal-level fields are saved before directory-level attributes |
| Trigger | ChangeUserAccount |
| Enforcement | Lines 408–433 (GivenName, Surname, Description, then Save() when modified) |
| Origin | service |
| Reached from | ChangeUserAccount |
| Outcome | Two separate directory writes per request; a partial failure can leave the two halves inconsistent* |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:408-433 |
BR-24 Directory attribute changes commit only when modified is raised¶
| Field | Value |
|---|---|
| Statement | Directory-level attribute changes are committed only when the modified flag was raised |
| Trigger | ChangeUserAccount, commit stage |
| Enforcement | If modified Then de.CommitChanges() — lines 458–460 |
| Origin | service |
| Reached from | ChangeUserAccount |
| Outcome | Changes to company and info alone never set the flag, so such a request returns Success having committed nothing (see Tech debt — TD-19) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:458-460 |
BR-25 A password change is applied via SetPassword and committed¶
| Field | Value |
|---|---|
| Statement | A password change is applied by invoking SetPassword on the user's underlying DirectoryEntry and committing |
| Trigger | ChangeUserPassword, ChangeUserPasswordOrGetException |
| Enforcement | ChangePassword — de.Invoke("SetPassword", oPassword) + de.CommitChanges(), lines 514–517 |
| Origin | service |
| Reached from | ChangeUserPassword, ChangeUserPasswordOrGetException |
| Outcome | On success Response = Success; an unknown user yields UserDoesNotExist before any write |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:514-517 |
BR-26 Two password-change variants differ only in error strategy¶
| Field | Value |
|---|---|
| Statement | Two published variants of the password change exist and differ only in error strategy: one absorbs exceptions into a failure code, the other lets them fault |
| Trigger | Caller's choice of operation |
| Enforcement | ChangeUserPassword wraps the call in Try/Catch (lines 478–483) versus ChangeUserPasswordOrGetException with no handler (lines 493–501) |
| Origin | service |
| Reached from | ChangeUserPassword, ChangeUserPasswordOrGetException |
| Outcome | Failure + exception text, versus a framework SOAP fault carrying the exception |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:478-483,493-501 |
BR-27 A directory attribute query returns the value or UserDoesNotExist¶
| Field | Value |
|---|---|
| Statement | A directory attribute query returns the attribute's value in Message, and UserDoesNotExist when the user cannot be resolved |
| Trigger | GetUserEmail, GetUserFAX, GetUserPrinter |
| Enforcement | myStruc.Message = de.Properties.Item("<attr>").Value — lines 546 (mail), 639 (facsimileTelephoneNumber), 707 (extensionAttribute2) |
| Origin | service |
| Reached from | GetUserEmail, GetUserFAX, GetUserPrinter |
| Outcome | The value is returned as a string in Message with Response = Success; an unset attribute surfaces as Failure + a .NET exception message* |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:546,639,707 |
BR-28 The group list includes nested membership¶
| Field | Value |
|---|---|
| Statement | The group list for a user is the names of all of the user's authorization groups, including nested membership |
| Trigger | GetUserGroups |
| Enforcement | For Each grp As GroupPrincipal In userPrin.GetAuthorizationGroups, calling userGroups.Add(grp.Name) — lines 570–574 |
| Origin | service |
| Reached from | GetUserGroups |
| Outcome | An ArrayList of group-name strings, untyped on the wire (see Tech debt — TD-05); empty on any failure |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:570-574 |
BR-29 A resource is a Resource-named group filtered by caller-supplied environment¶
| Field | Value |
|---|---|
| Statement | A "resource" is an authorization group whose name contains Resource; groups whose name also contains TestResource are returned only for non-production callers, and all other resource groups only for production callers |
| Trigger | GetUserResources |
| Enforcement | Lines 596–608 |
| Origin | service |
| Reached from | GetUserResources |
| Outcome | An ArrayList of the filtered group names. A production caller receives no TestResource group; a non-production caller receives only TestResource groups |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:596-608 |
BR-30 Last logon is derived from the FILETIME attribute¶
| Field | Value |
|---|---|
| Statement | Last-logon is derived from the lastLogonTimestamp FILETIME and rendered as a date-time string; a value below 1 means the information is unavailable |
| Trigger | GetUserLastLogon |
| Enforcement | Lines 664–678 — HighPart * 4294967296 + lowpart, < 1 test, division by 60 * 10000000, DateAdd(DateInterval.Minute, lastLogon, #1/1/1601#) |
| Origin | service |
| Reached from | GetUserLastLogon |
| Outcome | Message = the rendered timestamp with Response = Success, or Response = InfoNotAvailable. Rendering uses the server's culture and time zone* |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:664-678 |
BR-31 The printer attribute is read from the internal domain only¶
| Field | Value |
|---|---|
| Statement | The user's printer is read from extensionAttribute2 in the internal domain only |
| Trigger | GetUserPrinter |
| Enforcement | GetUserPrincipal(inUserId, False) (line 700) with the authored comment 'Assume internal domain |
| Origin | service |
| Reached from | GetUserPrinter |
| Outcome | External-domain users are always reported as UserDoesNotExist; the caller cannot select a domain |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:700 |
BR-32 Group membership for IsUserInGroup is direct only¶
| Field | Value |
|---|---|
| Statement | Group membership for IsUserInGroup is direct membership of the resolved group, not transitive authorization-group membership |
| Trigger | IsUserInGroup |
| Enforcement | If groupPrin.Members.Contains(userPrin) — lines 733–737 |
| Origin | service |
| Reached from | IsUserInGroup |
| Outcome | Message = the literal "True"/"False". A user who is a member only through a nested group answers "False", which differs from BR-22/BR-28 on the same underlying question |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:733-737 |
BR-33 A group is resolved by SamAccountName, taking the first match¶
| Field | Value |
|---|---|
| Statement | A group is resolved by matching SamAccountName through a PrincipalSearcher, taking the first match |
| Trigger | Any group lookup |
| Enforcement | GetGroupPrincipal — lines 880–886 |
| Origin | service |
| Reached from | AddUserToGroup, DeleteUserFromGroup, DoesUserHaveResource, IsUserInGroup |
| Outcome | An unresolved group yields GroupDoesNotExist in the calling operation. FindOne makes the result non-deterministic if more than one group matches* |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:880-886 |
BR-34 A directory lookup failure is reported as not found¶
| Field | Value |
|---|---|
| Statement | A directory lookup failure is captured into an internal field and reported to the caller as "not found" |
| Trigger | Any exception inside GetContext, GetUserPrincipal or GetGroupPrincipal |
| Enforcement | m_LastMessage = ex.Message + Return Nothing — lines 836, 850, 855, 888 |
| Origin | service |
| Reached from | every operation except HelloWorld and CheckAccess |
| Outcome | The caller receives UserDoesNotExist, GroupDoesNotExist, False, or an empty list — never the failure detail, which is written to a field no operation reads (see Tech debt — TD-07, TD-22) |
| Source | src\ADUserServices\clsADUserServices.asmx.vb:836,850,855,888 |
Note: Statements marked with * were inferred from code structure, naming conventions, or contextual evidence where direct confirmation was not available in source artifacts. Statements marked with ** could not be confirmed; treat as unknown until verified.
Related documentation¶
- Service index
- Overview
- Architecture
- Activation and hosting
- Operations
- Operations and processes
- Authentication and authorization
- Configuration
- Dependencies
- Faults and error pipeline
- Operational runbook
- Tech debt
Sources¶
src\ADUserServices\clsADUserServices.asmx.vb.saifdocgen\state\runs\20260922-101326-aduserservices\analysis\aduserservices-analysis.md