msad 0.3.5__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msad/__main__.py +4 -0
- msad/group.py +15 -36
- msad/main.py +339 -0
- msad/search.py +5 -2
- msad/user.py +13 -9
- msad-0.4.0.dist-info/METADATA +103 -0
- msad-0.4.0.dist-info/RECORD +12 -0
- msad-0.4.0.dist-info/entry_points.txt +2 -0
- msad/command_line.py +0 -265
- msad-0.3.5.dist-info/METADATA +0 -118
- msad-0.3.5.dist-info/RECORD +0 -11
- msad-0.3.5.dist-info/entry_points.txt +0 -2
- {msad-0.3.5.dist-info → msad-0.4.0.dist-info}/WHEEL +0 -0
- {msad-0.3.5.dist-info → msad-0.4.0.dist-info}/licenses/LICENSE +0 -0
msad/__main__.py
ADDED
msad/group.py
CHANGED
@@ -17,51 +17,37 @@
|
|
17
17
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
18
18
|
|
19
19
|
import logging
|
20
|
-
from .search import *
|
21
20
|
|
21
|
+
from .search import get_dn, search
|
22
22
|
|
23
|
-
def add_member(
|
24
|
-
|
25
|
-
):
|
26
|
-
if group_name:
|
27
|
-
group_dn = get_dn(conn, search_base, group_name)
|
23
|
+
def add_member(conn, search_base, group, user):
|
24
|
+
group_dn = get_dn(conn, search_base, group)
|
28
25
|
if not group_dn:
|
29
|
-
logging.error("group_name or group_dn must be passed and exist")
|
30
26
|
return None
|
31
27
|
|
32
|
-
|
33
|
-
user_dn = get_dn(conn, search_base, user_name)
|
28
|
+
user_dn = get_dn(conn, search_base, user)
|
34
29
|
if not user_dn:
|
35
|
-
logging.error("user_name or user_dn must be passed and exist")
|
36
30
|
return None
|
37
31
|
|
38
32
|
return conn.extend.microsoft.add_members_to_groups([user_dn], [group_dn])
|
39
33
|
|
40
34
|
|
41
|
-
def remove_member(
|
42
|
-
|
43
|
-
):
|
44
|
-
if group_name:
|
45
|
-
group_dn = get_dn(conn, search_base, group_name)
|
46
|
-
|
35
|
+
def remove_member(conn, search_base, group, user):
|
36
|
+
group_dn = get_dn(conn, search_base, group)
|
47
37
|
if not group_dn:
|
48
|
-
logging.error("group_name or group_dn must be passed and exist")
|
49
38
|
return None
|
50
|
-
if user_name:
|
51
|
-
user_dn = get_dn(conn, search_base, user_name)
|
52
39
|
|
40
|
+
user_dn = get_dn(conn, search_base, user)
|
53
41
|
if not user_dn:
|
54
|
-
logging.error("user_name or user_dn must be passed and exist")
|
55
42
|
return None
|
56
43
|
|
57
44
|
return conn.extend.microsoft.remove_members_from_groups([user_dn], [group_dn])
|
58
45
|
|
59
46
|
|
60
47
|
def group_flat_members(
|
61
|
-
conn, search_base, limit,
|
48
|
+
conn, search_base, limit, group, attributes=None
|
62
49
|
):
|
63
|
-
|
64
|
-
group_dn = get_dn(conn, search_base, group_name)
|
50
|
+
group_dn = get_dn(conn, search_base, group)
|
65
51
|
|
66
52
|
if not group_dn:
|
67
53
|
return None
|
@@ -70,30 +56,23 @@ def group_flat_members(
|
|
70
56
|
return search(conn, search_base, search_filter, attributes=attributes)
|
71
57
|
|
72
58
|
|
73
|
-
def group_members(conn, search_base,
|
74
|
-
|
75
|
-
group_dn = get_dn(conn, search_base, group_name)
|
59
|
+
def group_members(conn, search_base, group):
|
60
|
+
group_dn = get_dn(conn, search_base, group)
|
76
61
|
if not group_dn:
|
77
|
-
logging.error("group_name or group_dn must be passed and exist")
|
78
62
|
return None
|
79
63
|
|
80
64
|
search_filter = f"(distinguishedName={group_dn})"
|
81
65
|
return search(conn, group_dn, search_filter, limit=1, attributes=["member"])
|
82
66
|
|
83
67
|
|
84
|
-
def group_member(
|
85
|
-
|
86
|
-
)
|
87
|
-
if group_name:
|
88
|
-
group_dn = get_dn(conn, search_base, group_name)
|
68
|
+
def group_member(conn, search_base, group, user):
|
69
|
+
|
70
|
+
group_dn = get_dn(conn, search_base, group)
|
89
71
|
if not group_dn:
|
90
|
-
logging.error("group_name or group_dn must be passed and exist")
|
91
72
|
return None
|
92
73
|
|
93
|
-
|
94
|
-
user_dn = get_dn(conn, search_base, user_name)
|
74
|
+
user_dn = get_dn(conn, search_base, user)
|
95
75
|
if not user_dn:
|
96
|
-
logging.error("user_name or user_dn must be passed and exist")
|
97
76
|
return None
|
98
77
|
|
99
78
|
search_filter = f"(&(memberOf:1.2.840.113556.1.4.1941:={group_dn})(objectCategory=person)(objectClass=user)(distinguishedName={user_dn}))"
|
msad/main.py
ADDED
@@ -0,0 +1,339 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
# msad - Active Directory tool
|
4
|
+
# Copyright (C) 2025 - matteo.redaelli@gmail.com
|
5
|
+
|
6
|
+
# This program is free software: you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation, either version 3 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU General Public License for more details.
|
15
|
+
|
16
|
+
# You should have received a copy of the GNU General Public License
|
17
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>
|
18
|
+
|
19
|
+
import logging
|
20
|
+
import datetime
|
21
|
+
import os
|
22
|
+
import json
|
23
|
+
import ssl
|
24
|
+
import sys
|
25
|
+
import tomllib
|
26
|
+
|
27
|
+
import typer
|
28
|
+
|
29
|
+
import msad
|
30
|
+
import ldap3
|
31
|
+
|
32
|
+
from pathlib import Path
|
33
|
+
|
34
|
+
|
35
|
+
BANNER = """
|
36
|
+
|
37
|
+
"""
|
38
|
+
|
39
|
+
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
40
|
+
logging.info(BANNER)
|
41
|
+
|
42
|
+
def _get_domain_config(config: dict|None, domain: str|None):
|
43
|
+
if "defaults" not in config:
|
44
|
+
logging.error(f"Missing entry 'defaults' in config file. Bye!")
|
45
|
+
sys.exit(100)
|
46
|
+
if "domain" not in config["defaults"]:
|
47
|
+
logging.error("Missing entry 'domain' in section 'defaults' in config file. Bye!")
|
48
|
+
sys.exit(101)
|
49
|
+
|
50
|
+
default_domain = config["defaults"]["domain"]
|
51
|
+
|
52
|
+
if "domains" not in config:
|
53
|
+
logging.error("Missing section 'domains' in config file. Bye!")
|
54
|
+
sys.exit(102)
|
55
|
+
|
56
|
+
if default_domain not in config["domains"] :
|
57
|
+
logging.error(f"Missing section '{default_domain}' in section 'domains' in config file. Bye!")
|
58
|
+
sys.exit(103)
|
59
|
+
|
60
|
+
if domain and domain not in config["domains"] :
|
61
|
+
logging.error(f"Missing section '{domain}' in section 'domains' in config file. Bye!")
|
62
|
+
sys.exit(104)
|
63
|
+
|
64
|
+
# TODO: validateing domain sections
|
65
|
+
if not domain:
|
66
|
+
domain = default_domain
|
67
|
+
|
68
|
+
domain_config = config["domains"][domain]
|
69
|
+
for field in ["host", "port", "search_base", "use_ssl"]:
|
70
|
+
if field not in domain_config:
|
71
|
+
logging.error(f"Missing required field '{field}' in section 'domains.{domain}' in config file. Bye!")
|
72
|
+
sys.exit(105)
|
73
|
+
return domain_config
|
74
|
+
|
75
|
+
def _get_config(domain: str|None, config_file: str|None):
|
76
|
+
if not config_file:
|
77
|
+
home = Path.home()
|
78
|
+
config_file = home / ".msad.toml"
|
79
|
+
|
80
|
+
if not os.path.isfile(config_file):
|
81
|
+
logging.error(f"Missing file {config_file}. Bye!")
|
82
|
+
sys.exit(1)
|
83
|
+
|
84
|
+
if not os.access(config_file, os.R_OK):
|
85
|
+
logging.error(f"File {config_file} is not readable. Bye!")
|
86
|
+
sys.exit(2)
|
87
|
+
|
88
|
+
"""Read config file"""
|
89
|
+
with open(config_file, "rb") as f:
|
90
|
+
data = tomllib.load(f)
|
91
|
+
return _get_domain_config(data, domain)
|
92
|
+
|
93
|
+
def _json_converter(o):
|
94
|
+
if isinstance(o, datetime.datetime):
|
95
|
+
return o.__str__()
|
96
|
+
elif isinstance(o, list):
|
97
|
+
return ";".join(o)
|
98
|
+
# else return o
|
99
|
+
|
100
|
+
|
101
|
+
def _get_connection_krb(host: str, port: int, use_ssl: bool):
|
102
|
+
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
|
103
|
+
server = ldap3.Server(host, port=port, use_ssl=use_ssl, tls=tls)
|
104
|
+
|
105
|
+
conn = ldap3.Connection(
|
106
|
+
server,
|
107
|
+
authentication=ldap3.SASL,
|
108
|
+
sasl_mechanism=ldap3.KERBEROS,
|
109
|
+
auto_bind=False,
|
110
|
+
)
|
111
|
+
# conn.bind()
|
112
|
+
return conn
|
113
|
+
|
114
|
+
|
115
|
+
def _get_connection_user_pwd(host: str, port: int, use_ssl: bool, user: str, password: str):
|
116
|
+
server = ldap3.Server(host, port=port, use_ssl=use_ssl)
|
117
|
+
|
118
|
+
conn = ldap3.Connection(server, user=user, password=password, auto_bind=False)
|
119
|
+
# conn.bind()
|
120
|
+
return conn
|
121
|
+
|
122
|
+
|
123
|
+
def _get_connection(config: dict):
|
124
|
+
if "user" in config and "password" in config:
|
125
|
+
conn = _get_connection_user_pwd(config["host"],
|
126
|
+
config["port"],
|
127
|
+
config["use_ssl"],
|
128
|
+
config["user"],
|
129
|
+
config["password"])
|
130
|
+
else:
|
131
|
+
conn = _get_connection_krb(config["host"],
|
132
|
+
config["port"],
|
133
|
+
config["use_ssl"])
|
134
|
+
conn.bind()
|
135
|
+
return conn
|
136
|
+
|
137
|
+
def _pprint(ldapresult, out_format="json", sep="\t"):
|
138
|
+
if not ldapresult or out_format == "default":
|
139
|
+
return ldapresult
|
140
|
+
elif out_format == "json1":
|
141
|
+
return json.dumps(dict(ldapresult))
|
142
|
+
else:
|
143
|
+
result = ""
|
144
|
+
for obj in ldapresult:
|
145
|
+
if out_format == "json":
|
146
|
+
result = (
|
147
|
+
result + json.dumps(dict(obj), default=_json_converter) + "\n"
|
148
|
+
)
|
149
|
+
elif out_format == "csv":
|
150
|
+
sorted_obj = dict(sorted(obj.items()))
|
151
|
+
new_values = list(
|
152
|
+
map(
|
153
|
+
lambda v: "|".join(v) if isinstance(v, list) else v,
|
154
|
+
sorted_obj.values(),
|
155
|
+
)
|
156
|
+
)
|
157
|
+
result = result + sep.join(new_values) + "\n"
|
158
|
+
return result
|
159
|
+
|
160
|
+
# def users(self, user):
|
161
|
+
# """Find users inside AD. The
|
162
|
+
# filter can be the cn or userPrincipalName or samaccoutnname or mail to be searched. Can contain *
|
163
|
+
# """
|
164
|
+
# result = msad.users(
|
165
|
+
# self._conn, self._search_base, user, attributes=self._attributes
|
166
|
+
# )
|
167
|
+
# return self._pprint(result)
|
168
|
+
|
169
|
+
# def is_disabled(self, user):
|
170
|
+
# """Check if a user is disabled"""
|
171
|
+
# return msad.user.is_disabled(self._conn, self._search_base, user)
|
172
|
+
|
173
|
+
# def is_locked(self, user):
|
174
|
+
# """Check if the user is locked"""
|
175
|
+
# return msad.user.is_locked(self._conn, self._search_base, user)
|
176
|
+
|
177
|
+
# def password_changed_in_days(self, user):
|
178
|
+
# return msad.user.password_changed_in_days(self._conn, self._search_base, user)
|
179
|
+
|
180
|
+
# def has_expired_password(self, user, max_age):
|
181
|
+
# """Check is user has the expired password"""
|
182
|
+
# return msad.has_expired_password(self._conn, self._search_base, user, max_age)
|
183
|
+
|
184
|
+
# def has_never_expires_password(self, user):
|
185
|
+
# """Check if a user has never expires password"""
|
186
|
+
# return msad.has_never_expires_password(self._conn, self._search_base, user)
|
187
|
+
|
188
|
+
# def check_user(self, user, max_age, groups=[]):
|
189
|
+
# """Get some info about a user: is it locked? disabled? password expired?"""
|
190
|
+
# return msad.check_user(self._conn, self._search_base, user, max_age, groups)
|
191
|
+
|
192
|
+
|
193
|
+
|
194
|
+
# def user_groups(self, user_name=None, user_dn=None):
|
195
|
+
# """Extract the list of groups of a user (using DN or sAMAccountName)"""
|
196
|
+
# return msad.user.user_groups(
|
197
|
+
# self._conn, self._search_base, self._limit, user_name, user_dn
|
198
|
+
# )
|
199
|
+
|
200
|
+
|
201
|
+
# def group_member(
|
202
|
+
# self, group_name=None, group_dn=None, user_name=None, user_dn=None
|
203
|
+
# ):
|
204
|
+
# """Check if the user is a member of a group (using DN or sAMAccountName)"""
|
205
|
+
# return msad.group_member(
|
206
|
+
# conn=self._conn,
|
207
|
+
# search_base=self._search_base,
|
208
|
+
# group_name=group_name,
|
209
|
+
# group_dn=group_dn,
|
210
|
+
# user_name=user_name,
|
211
|
+
# user_dn=user_dn,
|
212
|
+
# )
|
213
|
+
|
214
|
+
|
215
|
+
|
216
|
+
app = typer.Typer()
|
217
|
+
|
218
|
+
@app.command()
|
219
|
+
def change_password(user: str,
|
220
|
+
domain: str|None = None,
|
221
|
+
config_file: str|None = None):
|
222
|
+
config = _get_config(config_file, domain)
|
223
|
+
conn = _get_connection(config)
|
224
|
+
return msad.user.change_password(
|
225
|
+
conn, config["search_base"], user)
|
226
|
+
|
227
|
+
@app.command()
|
228
|
+
def group_add_member(group: str,
|
229
|
+
user: str,
|
230
|
+
domain: str|None = None,
|
231
|
+
config_file: str|None = None):
|
232
|
+
"""Adds the user to a group (using DN or sAMAccountName)"""
|
233
|
+
|
234
|
+
config = _get_config(config_file, domain)
|
235
|
+
conn = _get_connection(config)
|
236
|
+
result = msad.add_member(
|
237
|
+
conn=conn,
|
238
|
+
search_base=config["search_base"],
|
239
|
+
group=group,
|
240
|
+
user=user,
|
241
|
+
)
|
242
|
+
return result
|
243
|
+
|
244
|
+
@app.command()
|
245
|
+
def group_remove_member(group: str,
|
246
|
+
user: str,
|
247
|
+
domain: str|None = None,
|
248
|
+
config_file: str|None = None):
|
249
|
+
"""Remove the user to a group (using DN or sAMAccountName)"""
|
250
|
+
|
251
|
+
config = _get_config(config_file, domain)
|
252
|
+
conn = _get_connection(config)
|
253
|
+
result = msad.remove_member(
|
254
|
+
conn=conn,
|
255
|
+
search_base=config["search_base"],
|
256
|
+
group=group,
|
257
|
+
user=user,
|
258
|
+
)
|
259
|
+
return result
|
260
|
+
|
261
|
+
@app.command()
|
262
|
+
def group_members(group: str,
|
263
|
+
nested: bool=False,
|
264
|
+
limit: int = 2000,
|
265
|
+
domain: str|None = None,
|
266
|
+
config_file: str|None = None,
|
267
|
+
out_format: str = "json",
|
268
|
+
attributes: list[str] = []):
|
269
|
+
|
270
|
+
config = _get_config(config_file, domain)
|
271
|
+
conn = _get_connection(config)
|
272
|
+
if nested:
|
273
|
+
result = msad.group_flat_members(
|
274
|
+
conn,
|
275
|
+
config["search_base"],
|
276
|
+
limit,
|
277
|
+
group,
|
278
|
+
attributes=attributes,
|
279
|
+
)
|
280
|
+
else:
|
281
|
+
"""Extract the direct members of a group"""
|
282
|
+
result = msad.group_members(
|
283
|
+
conn,
|
284
|
+
config["search_base"],
|
285
|
+
group)
|
286
|
+
print(_pprint(result))
|
287
|
+
|
288
|
+
@app.command()
|
289
|
+
def search(filter: str,
|
290
|
+
limit: int = 2000,
|
291
|
+
domain: str|None = None,
|
292
|
+
config_file: str|None = None,
|
293
|
+
out_format: str = "json",
|
294
|
+
attributes: list[str] = []):
|
295
|
+
config = _get_config(config_file, domain)
|
296
|
+
conn = _get_connection(config)
|
297
|
+
result = msad.search(conn, config["search_base"], filter, limit=limit, attributes=attributes)
|
298
|
+
print(_pprint(result, out_format))
|
299
|
+
|
300
|
+
@app.command()
|
301
|
+
def get_sample_config():
|
302
|
+
output = """
|
303
|
+
[defaults]
|
304
|
+
|
305
|
+
domain = "mydomain"
|
306
|
+
|
307
|
+
[domains]
|
308
|
+
|
309
|
+
[domains.mydomain]
|
310
|
+
|
311
|
+
host = "example.com"
|
312
|
+
search_base = "dc=example,dc=com"
|
313
|
+
|
314
|
+
port = 636
|
315
|
+
use_ssl = true
|
316
|
+
#port = 389
|
317
|
+
#use_ssl = false
|
318
|
+
|
319
|
+
# user =
|
320
|
+
# password =
|
321
|
+
"""
|
322
|
+
print(output)
|
323
|
+
|
324
|
+
@app.command()
|
325
|
+
def user_groups(user: str,
|
326
|
+
nested: bool=False,
|
327
|
+
limit: int = 2000,
|
328
|
+
domain: str|None = None,
|
329
|
+
config_file: str|None = None,
|
330
|
+
out_format: str = "json"):
|
331
|
+
|
332
|
+
config = _get_config(config_file, domain)
|
333
|
+
conn = _get_connection(config)
|
334
|
+
|
335
|
+
result = msad.user.user_groups(conn, config["search_base"], limit, user, nested=nested)
|
336
|
+
print(_pprint(result, out_format))
|
337
|
+
|
338
|
+
if __name__ == "__main__":
|
339
|
+
app()
|
msad/search.py
CHANGED
@@ -53,11 +53,14 @@ def users(conn, search_base, string, limit, attributes=None):
|
|
53
53
|
return search(conn, search_base, search_filter, limit=limit, attributes=attributes)
|
54
54
|
|
55
55
|
|
56
|
-
def get_dn(conn, search_base,
|
57
|
-
|
56
|
+
def get_dn(conn, search_base, entry):
|
57
|
+
if entry.lower().startswith("cn="):
|
58
|
+
return entry
|
59
|
+
search_filter = f"(sAMAccountName={entry})"
|
58
60
|
result = search(conn, search_base, search_filter, attributes=["distinguishedName"])
|
59
61
|
logging.debug(result)
|
60
62
|
if len(result) < 1:
|
63
|
+
logging.error(f"entry {entry} not found")
|
61
64
|
return None
|
62
65
|
|
63
66
|
return result[0]["distinguishedName"]
|
msad/user.py
CHANGED
@@ -20,7 +20,7 @@ import logging
|
|
20
20
|
import getpass
|
21
21
|
import ldap3
|
22
22
|
import datetime
|
23
|
-
from .search import
|
23
|
+
from .search import get_dn, search
|
24
24
|
from .group import *
|
25
25
|
|
26
26
|
|
@@ -34,9 +34,8 @@ def _enter_password(text):
|
|
34
34
|
return p
|
35
35
|
|
36
36
|
|
37
|
-
def change_password(conn, search_base,
|
38
|
-
|
39
|
-
user_dn = get_dn(conn, search_base, user_name)
|
37
|
+
def change_password(conn, search_base, user):
|
38
|
+
user_dn = get_dn(conn, search_base, user)
|
40
39
|
|
41
40
|
if not user_dn:
|
42
41
|
return None
|
@@ -134,16 +133,21 @@ def check_user(conn, search_base, user, max_age, groups=[]):
|
|
134
133
|
)
|
135
134
|
|
136
135
|
|
137
|
-
def user_groups(conn, search_base, limit,
|
136
|
+
def user_groups(conn, search_base, limit, user, nested=True):
|
138
137
|
"""retrieve all groups (also nested) of a user"""
|
139
138
|
|
140
|
-
|
141
|
-
user_dn = get_dn(conn, search_base, user_name)
|
139
|
+
user_dn = get_dn(conn, search_base, user)
|
142
140
|
|
143
141
|
if not user_dn:
|
144
142
|
return None
|
145
143
|
|
146
|
-
|
144
|
+
if nested:
|
145
|
+
search_filter = f"(member:1.2.840.113556.1.4.1941:={user_dn})"
|
146
|
+
attributes = ["sAMaccountName"]
|
147
|
+
else:
|
148
|
+
search_filter = "(objectClass=*)"
|
149
|
+
search_base = user_dn
|
150
|
+
attributes = ["memberOf"]
|
147
151
|
return search(
|
148
|
-
conn, search_base, search_filter, limit=limit, attributes=
|
152
|
+
conn, search_base, search_filter, limit=limit, attributes=attributes
|
149
153
|
)
|
@@ -0,0 +1,103 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: msad
|
3
|
+
Version: 0.4.0
|
4
|
+
Summary: msad is a commandline for interacting with Active Directory
|
5
|
+
Project-URL: Homepage, https://github.com/matteoredaelli/msad
|
6
|
+
Project-URL: Issues, https://github.com/matteoredaelli/msad/issues
|
7
|
+
Author-email: Matteo Redaelli <matteo.redaelli@gmail.com>
|
8
|
+
License-Expression: GPL-3.0-or-later
|
9
|
+
License-File: LICENSE
|
10
|
+
Classifier: Operating System :: OS Independent
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
12
|
+
Requires-Python: >=3.9
|
13
|
+
Requires-Dist: cryptography
|
14
|
+
Requires-Dist: gssapi
|
15
|
+
Requires-Dist: ldap3
|
16
|
+
Requires-Dist: typer
|
17
|
+
Description-Content-Type: text/markdown
|
18
|
+
|
19
|
+
# msAD
|
20
|
+
|
21
|
+
*msad* is a library and command line tool for working with an Active Directory / LDAP server from Unix, Linux and MacOs systems.
|
22
|
+
|
23
|
+
It supports authentication with user/pwd and kerberos
|
24
|
+
|
25
|
+
It supports paginations: it can retreive more than 2000 objects (a limit of AD)
|
26
|
+
|
27
|
+
It can be used for:
|
28
|
+
- search objects (users, groups, computers,..)
|
29
|
+
- search (recursively) group memberships and all user's groups
|
30
|
+
- add/remove members to/from AD groups using DN or sAMaccoutName
|
31
|
+
- change AD passwords
|
32
|
+
- check if a user is disabled or locked, group membership
|
33
|
+
|
34
|
+
## Prerequisites
|
35
|
+
|
36
|
+
python >= 3.9
|
37
|
+
|
38
|
+
For kerboros auth
|
39
|
+
|
40
|
+
- krb5 lib and tools (like kinit, ...)
|
41
|
+
- a keytab file or
|
42
|
+
|
43
|
+
## Installation
|
44
|
+
|
45
|
+
```bash
|
46
|
+
pipx install msad
|
47
|
+
```
|
48
|
+
|
49
|
+
## COnfiguration
|
50
|
+
|
51
|
+
Create a configuration file in $HOME/.msad.toml as suggested by
|
52
|
+
|
53
|
+
```bash
|
54
|
+
msad get-sample-config
|
55
|
+
```
|
56
|
+
|
57
|
+
|
58
|
+
## Usage
|
59
|
+
|
60
|
+
|
61
|
+
```bash
|
62
|
+
msad --help
|
63
|
+
|
64
|
+
python -m msad --help
|
65
|
+
|
66
|
+
```
|
67
|
+
|
68
|
+
|
69
|
+
For kerberos authentication, first you need to login to AD / get a ticket kerberos with
|
70
|
+
|
71
|
+
```bash
|
72
|
+
kinit youraduser
|
73
|
+
```
|
74
|
+
|
75
|
+
|
76
|
+
|
77
|
+
```text
|
78
|
+
msad search "(samaccountname=matteo)" --out-format=json
|
79
|
+
|
80
|
+
msad search "(cn=redaelli*)" --attribute mail --attribute samaccountname --out-format=json
|
81
|
+
|
82
|
+
msad group-members qlik_analyzer_users --nested
|
83
|
+
|
84
|
+
msad group-add-member qlik_analyzer_users matteo
|
85
|
+
|
86
|
+
msad group-remove-member qlik_analyzer_users matteo
|
87
|
+
|
88
|
+
msad user-groups matteo --nested
|
89
|
+
|
90
|
+
```
|
91
|
+
|
92
|
+
## Sample
|
93
|
+
|
94
|
+
|
95
|
+
|
96
|
+
## License
|
97
|
+
|
98
|
+
Copyright © 2021 - 2025 Matteo Redaelli
|
99
|
+
|
100
|
+
This program is free software: you can redistribute it and/or modify
|
101
|
+
it under the terms of the GNU General Public License as published by
|
102
|
+
the Free Software Foundation, either version 3 of the License, or
|
103
|
+
(at your option) any later version.
|
@@ -0,0 +1,12 @@
|
|
1
|
+
msad/__init__.py,sha256=DJ6egXlvzOK_k0XlN7BpmMRuwu4MM7eQoxLE0Ou-sLI,63
|
2
|
+
msad/__main__.py,sha256=RCZmmoCNOWC7rAfIDm_LaymsybXIzE6McYbUEEkf9P8,60
|
3
|
+
msad/ad.py,sha256=C3dknAgRY6Jnotk0RgPSye7uzNxUd-7B3oGloGR674E,5679
|
4
|
+
msad/group.py,sha256=3HNpXfYK4yXxDNsXn72JOZSMEwjj0kMiDvVBca1oVuI,2510
|
5
|
+
msad/main.py,sha256=5wBSPOZPkOnYfe-gow1YFt65FpawJooZ3eOKp_RFP0A,10573
|
6
|
+
msad/search.py,sha256=j8PedOZVf7eFBG34ZfHoVEfEPbYE0nOtznKTLwu48Eg,3476
|
7
|
+
msad/user.py,sha256=k9INOsuSekCB4w0EYYrcgPQChvOfawIzLYv7h37-DXM,4557
|
8
|
+
msad-0.4.0.dist-info/METADATA,sha256=KL37POSU35W_FjShUlAU8Qf48EVUpGwPkCbWMUmQ5T0,2288
|
9
|
+
msad-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
10
|
+
msad-0.4.0.dist-info/entry_points.txt,sha256=UKSjeppC0YX2dfybmBfxLXCqF_HMZFfPX1MRw88rtpI,39
|
11
|
+
msad-0.4.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
12
|
+
msad-0.4.0.dist-info/RECORD,,
|
msad/command_line.py
DELETED
@@ -1,265 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
|
3
|
-
# msad - Active Directory tool
|
4
|
-
# Copyright (C) 2020 - matteo.redaelli@gmail.com
|
5
|
-
|
6
|
-
# This program is free software: you can redistribute it and/or modify
|
7
|
-
# it under the terms of the GNU General Public License as published by
|
8
|
-
# the Free Software Foundation, either version 3 of the License, or
|
9
|
-
# (at your option) any later version.
|
10
|
-
|
11
|
-
# This program is distributed in the hope that it will be useful,
|
12
|
-
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
-
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
-
# GNU General Public License for more details.
|
15
|
-
|
16
|
-
# You should have received a copy of the GNU General Public License
|
17
|
-
# along with this program. If not, see <https://www.gnu.org/licenses/>
|
18
|
-
|
19
|
-
import logging
|
20
|
-
import os
|
21
|
-
import datetime
|
22
|
-
import sys
|
23
|
-
import msad
|
24
|
-
import fire
|
25
|
-
import ldap3
|
26
|
-
import ssl
|
27
|
-
import json
|
28
|
-
from typing import List, Tuple, Dict
|
29
|
-
import pprint
|
30
|
-
|
31
|
-
|
32
|
-
def _json_converter(o):
|
33
|
-
if isinstance(o, datetime.datetime):
|
34
|
-
return o.__str__()
|
35
|
-
elif isinstance(o, list):
|
36
|
-
return ";".join(o)
|
37
|
-
# else return o
|
38
|
-
|
39
|
-
|
40
|
-
def _get_connection_krb(host, port, use_ssl):
|
41
|
-
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2)
|
42
|
-
server = ldap3.Server(host, port=port, use_ssl=use_ssl, tls=tls)
|
43
|
-
|
44
|
-
conn = ldap3.Connection(
|
45
|
-
server,
|
46
|
-
authentication=ldap3.SASL,
|
47
|
-
sasl_mechanism=ldap3.KERBEROS,
|
48
|
-
auto_bind=False,
|
49
|
-
)
|
50
|
-
# conn.bind()
|
51
|
-
return conn
|
52
|
-
|
53
|
-
|
54
|
-
def _get_connection_user_pwd(host, port, use_ssl, user, password):
|
55
|
-
server = ldap3.Server(host, port, use_ssl)
|
56
|
-
|
57
|
-
conn = ldap3.Connection(server, user=user, password=password, auto_bind=False)
|
58
|
-
# conn.bind()
|
59
|
-
return conn
|
60
|
-
|
61
|
-
|
62
|
-
def _get_connection(host, port, use_ssl, sso, user, password):
|
63
|
-
if user and password:
|
64
|
-
conn = _get_connection_user_pwd(host, port, use_ssl, user, password)
|
65
|
-
else:
|
66
|
-
conn = _get_connection_krb(host, port, use_ssl)
|
67
|
-
|
68
|
-
conn.bind()
|
69
|
-
return conn
|
70
|
-
|
71
|
-
|
72
|
-
class AD:
|
73
|
-
"""*msad* is command line tool for Active Directory. With it you can
|
74
|
-
search objects,
|
75
|
-
add/remove members to/from groups,
|
76
|
-
change password
|
77
|
-
check if a user is locked, disabled
|
78
|
-
check if a user's password is expired
|
79
|
-
..."""
|
80
|
-
|
81
|
-
def __init__(
|
82
|
-
self,
|
83
|
-
host,
|
84
|
-
port,
|
85
|
-
use_ssl=True,
|
86
|
-
sso=True,
|
87
|
-
user=None,
|
88
|
-
password=None,
|
89
|
-
search_base=None,
|
90
|
-
limit=0,
|
91
|
-
attributes=None,
|
92
|
-
out_format="default",
|
93
|
-
sep=";",
|
94
|
-
):
|
95
|
-
try:
|
96
|
-
self._conn = _get_connection(host, port, use_ssl, sso, user, password)
|
97
|
-
except:
|
98
|
-
logging.error(
|
99
|
-
f"Cannot login to Active Directory (host: {host}, port: {port}). Bye"
|
100
|
-
)
|
101
|
-
sys.exit(1)
|
102
|
-
self._attributes = attributes
|
103
|
-
self._sep = sep
|
104
|
-
self._search_base = search_base
|
105
|
-
self._limit = limit
|
106
|
-
self._out_format = out_format
|
107
|
-
|
108
|
-
def change_password(self, user_name=None, user_dn=None):
|
109
|
-
return msad.user.change_password(
|
110
|
-
self._conn, self._search_base, user_name, user_dn
|
111
|
-
)
|
112
|
-
|
113
|
-
def search(self, search_filter):
|
114
|
-
self._conn.search(
|
115
|
-
self._search_base,
|
116
|
-
search_filter,
|
117
|
-
size_limit=self._limit,
|
118
|
-
attributes=self._attributes,
|
119
|
-
)
|
120
|
-
result = list(filter(lambda e: "attributes" in e, self._conn.response))
|
121
|
-
result = list(map(lambda e: e["attributes"], result))
|
122
|
-
return self._pprint(result)
|
123
|
-
|
124
|
-
def _pprint(self, ldapresult):
|
125
|
-
if not ldapresult or self._out_format == "default":
|
126
|
-
return ldapresult
|
127
|
-
elif self._out_format == "json1":
|
128
|
-
return json.dumps(dict(ldapresult))
|
129
|
-
else:
|
130
|
-
result = ""
|
131
|
-
for obj in ldapresult:
|
132
|
-
if self._out_format == "json":
|
133
|
-
result = (
|
134
|
-
result + json.dumps(dict(obj), default=_json_converter) + "\n"
|
135
|
-
)
|
136
|
-
elif self._out_format == "csv":
|
137
|
-
sorted_obj = dict(sorted(obj.items()))
|
138
|
-
new_values = list(
|
139
|
-
map(
|
140
|
-
lambda v: "|".join(v) if isinstance(v, list) else v,
|
141
|
-
sorted_obj.values(),
|
142
|
-
)
|
143
|
-
)
|
144
|
-
result = result + self._sep.join(new_values) + "\n"
|
145
|
-
return result
|
146
|
-
|
147
|
-
def users(self, user):
|
148
|
-
"""Find users inside AD. The
|
149
|
-
filter can be the cn or userPrincipalName or samaccoutnname or mail to be searched. Can contain *
|
150
|
-
"""
|
151
|
-
result = msad.users(
|
152
|
-
self._conn, self._search_base, user, attributes=self._attributes
|
153
|
-
)
|
154
|
-
return self._pprint(result)
|
155
|
-
|
156
|
-
def is_disabled(self, user):
|
157
|
-
"""Check if a user is disabled"""
|
158
|
-
return msad.user.is_disabled(self._conn, self._search_base, user)
|
159
|
-
|
160
|
-
def is_locked(self, user):
|
161
|
-
"""Check if the user is locked"""
|
162
|
-
return msad.user.is_locked(self._conn, self._search_base, user)
|
163
|
-
|
164
|
-
def password_changed_in_days(self, user):
|
165
|
-
return msad.user.password_changed_in_days(self._conn, self._search_base, user)
|
166
|
-
|
167
|
-
def has_expired_password(self, user, max_age):
|
168
|
-
"""Check is user has the expired password"""
|
169
|
-
return msad.has_expired_password(self._conn, self._search_base, user, max_age)
|
170
|
-
|
171
|
-
def has_never_expires_password(self, user):
|
172
|
-
"""Check if a user has never expires password"""
|
173
|
-
return msad.has_never_expires_password(self._conn, self._search_base, user)
|
174
|
-
|
175
|
-
def check_user(self, user, max_age, groups=[]):
|
176
|
-
"""Get some info about a user: is it locked? disabled? password expired?"""
|
177
|
-
return msad.check_user(self._conn, self._search_base, user, max_age, groups)
|
178
|
-
|
179
|
-
def group_flat_members(self, group_name=None, group_dn=None):
|
180
|
-
"""Extract all the (nested) members of a group"""
|
181
|
-
result = msad.group_flat_members(
|
182
|
-
self._conn,
|
183
|
-
self._search_base,
|
184
|
-
self._limit,
|
185
|
-
group_name,
|
186
|
-
group_dn,
|
187
|
-
attributes=self._attributes,
|
188
|
-
)
|
189
|
-
return self._pprint(result)
|
190
|
-
|
191
|
-
def group_members(self, group_name=None, group_dn=None):
|
192
|
-
"""Extract the direct members of a group"""
|
193
|
-
if group_name is None and group_dn is None:
|
194
|
-
logging.error("group_name or group_dn must be entered")
|
195
|
-
return None
|
196
|
-
result = msad.group_members(self._conn, self._search_base, group_name, group_dn)
|
197
|
-
return self._pprint(result)
|
198
|
-
|
199
|
-
def add_member(self, group_name=None, group_dn=None, user_name=None, user_dn=None):
|
200
|
-
"""Adds the user to a group (using DN or sAMAccountName)"""
|
201
|
-
return msad.add_member(
|
202
|
-
conn=self._conn,
|
203
|
-
search_base=self._search_base,
|
204
|
-
group_name=group_name,
|
205
|
-
group_dn=group_dn,
|
206
|
-
user_name=user_name,
|
207
|
-
user_dn=user_dn,
|
208
|
-
)
|
209
|
-
|
210
|
-
def user_groups(self, user_name=None, user_dn=None):
|
211
|
-
"""Extract the list of groups of a user (using DN or sAMAccountName)"""
|
212
|
-
return msad.user.user_groups(
|
213
|
-
self._conn, self._search_base, self._limit, user_name, user_dn
|
214
|
-
)
|
215
|
-
|
216
|
-
def remove_member(
|
217
|
-
self, group_name=None, group_dn=None, user_name=None, user_dn=None
|
218
|
-
):
|
219
|
-
"""Remove the user from a group (using DN or sAMAccountName)"""
|
220
|
-
return msad.remove_member(
|
221
|
-
conn=self._conn,
|
222
|
-
search_base=self._search_base,
|
223
|
-
group_name=group_name,
|
224
|
-
group_dn=group_dn,
|
225
|
-
user_name=user_name,
|
226
|
-
user_dn=user_dn,
|
227
|
-
)
|
228
|
-
|
229
|
-
def group_member(
|
230
|
-
self, group_name=None, group_dn=None, user_name=None, user_dn=None
|
231
|
-
):
|
232
|
-
"""Check if the user is a member of a group (using DN or sAMAccountName)"""
|
233
|
-
return msad.group_member(
|
234
|
-
conn=self._conn,
|
235
|
-
search_base=self._search_base,
|
236
|
-
group_name=group_name,
|
237
|
-
group_dn=group_dn,
|
238
|
-
user_name=user_name,
|
239
|
-
user_dn=user_dn,
|
240
|
-
)
|
241
|
-
|
242
|
-
|
243
|
-
BANNER = """
|
244
|
-
__ __ ____ _ ____
|
245
|
-
| \/ |/ ___| / \ | _ \
|
246
|
-
| |\/| |\___ \ / _ \ | | | |
|
247
|
-
| | | | ___) |/ ___ \ | |_| |
|
248
|
-
|_| |_||____//_/ \_\|____/
|
249
|
-
|
250
|
-
https://github.com/matteoredaelli/msad
|
251
|
-
|
252
|
-
https://pypi.org/project/msad/
|
253
|
-
|
254
|
-
"""
|
255
|
-
|
256
|
-
|
257
|
-
def main():
|
258
|
-
"""main"""
|
259
|
-
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
260
|
-
logging.info(BANNER)
|
261
|
-
fire.Fire(AD)
|
262
|
-
|
263
|
-
|
264
|
-
if __name__ == "__main__":
|
265
|
-
main()
|
msad-0.3.5.dist-info/METADATA
DELETED
@@ -1,118 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: msad
|
3
|
-
Version: 0.3.5
|
4
|
-
Summary: msad is a commandline for interacting with Active Directory
|
5
|
-
Project-URL: Homepage, https://github.com/matteoredaelli/msad
|
6
|
-
Project-URL: Issues, https://github.com/matteoredaelli/msad/issues
|
7
|
-
Author-email: Matteo Redaelli <matteo.redaelli@gmail.com>
|
8
|
-
License-Expression: GPL-3.0-or-later
|
9
|
-
License-File: LICENSE
|
10
|
-
Classifier: Operating System :: OS Independent
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
12
|
-
Requires-Python: >=3.9
|
13
|
-
Requires-Dist: cryptography
|
14
|
-
Requires-Dist: fire
|
15
|
-
Requires-Dist: gssapi
|
16
|
-
Requires-Dist: ldap3
|
17
|
-
Description-Content-Type: text/markdown
|
18
|
-
|
19
|
-
# msAD
|
20
|
-
|
21
|
-
|
22
|
-
msad is a library and command line tool for working with an Active Directory / LDAP server. It can be used for:
|
23
|
-
- search objects (users, groups, computers,..)
|
24
|
-
- search group members
|
25
|
-
- add/remove members to/from AD groups using DN or sAMaccoutName
|
26
|
-
- change AD passwords
|
27
|
-
- check if a user is disabled or locked, group membership
|
28
|
-
|
29
|
-
|
30
|
-
## Usage
|
31
|
-
|
32
|
-
```bash
|
33
|
-
msad --help
|
34
|
-
```
|
35
|
-
|
36
|
-
```text
|
37
|
-
COMMAND is one of the following:
|
38
|
-
|
39
|
-
add_member
|
40
|
-
Adds the user to a group (using DN or sAMAccountName)
|
41
|
-
|
42
|
-
change_password
|
43
|
-
|
44
|
-
check_user
|
45
|
-
Get some info about a user: is it locked? disabled? password expired?
|
46
|
-
|
47
|
-
group_flat_members
|
48
|
-
Extract all the (nested) members of a group
|
49
|
-
|
50
|
-
group_member
|
51
|
-
Check if the user is a member of a group (using DN or sAMAccountName)
|
52
|
-
|
53
|
-
group_members
|
54
|
-
Extract the direct members of a group
|
55
|
-
|
56
|
-
has_expired_password
|
57
|
-
Check is user has the expired password
|
58
|
-
|
59
|
-
has_never_expires_password
|
60
|
-
Check if a user has never expires password
|
61
|
-
|
62
|
-
is_disabled
|
63
|
-
Check if a user is disabled
|
64
|
-
|
65
|
-
is_locked
|
66
|
-
Check if the user is locked
|
67
|
-
|
68
|
-
remove_member
|
69
|
-
Remove the user from a group (using DN or sAMAccountName)
|
70
|
-
|
71
|
-
search
|
72
|
-
|
73
|
-
user_groups
|
74
|
-
Extract the list of groups of a user (using DN or sAMAccountName)
|
75
|
-
|
76
|
-
users
|
77
|
-
Find users inside AD. The filter can be the cn or userPrincipalName or samaccoutnname or mail to be searched. Can contain *
|
78
|
-
|
79
|
-
```
|
80
|
-
|
81
|
-
## Sample
|
82
|
-
|
83
|
-
I find useful to add an alias in my ~/.bash_aliases
|
84
|
-
|
85
|
-
```bash
|
86
|
-
alias msad='/usr/local/bin/msad --host=dmc1it.group.redaelli.org --port=636 --search_base dc=group,dc=redaelli,dc=org'
|
87
|
-
```
|
88
|
-
|
89
|
-
Retreive info about a user
|
90
|
-
|
91
|
-
```bash
|
92
|
-
msad check_user matteo 90 \[qliksense_analyzer,qliksense_professional\] 2>/dev/null
|
93
|
-
```
|
94
|
-
|
95
|
-
```json
|
96
|
-
{"is_disabled": false}
|
97
|
-
{"is_locked": false}
|
98
|
-
{"has_never_expires_password": false}
|
99
|
-
{"has_expired_password": false}
|
100
|
-
{"membership_qliksense_analyzer": false}
|
101
|
-
{"membership_qliksense_professional": true}
|
102
|
-
```
|
103
|
-
|
104
|
-
Getting nested group members (it is a pages search, it can retreive more than 1000 users)
|
105
|
-
|
106
|
-
```bash
|
107
|
-
msad --out_format csv --attributes samaccountname,mail,sn,givenName group_flat_members "dc=group,dc=redaelli,dc=org" --group_name "qliksense_admin"
|
108
|
-
```
|
109
|
-
|
110
|
-
|
111
|
-
## License
|
112
|
-
|
113
|
-
Copyright © 2021 2022 Matteo Redaelli
|
114
|
-
|
115
|
-
This program is free software: you can redistribute it and/or modify
|
116
|
-
it under the terms of the GNU General Public License as published by
|
117
|
-
the Free Software Foundation, either version 3 of the License, or
|
118
|
-
(at your option) any later version.
|
msad-0.3.5.dist-info/RECORD
DELETED
@@ -1,11 +0,0 @@
|
|
1
|
-
msad/__init__.py,sha256=DJ6egXlvzOK_k0XlN7BpmMRuwu4MM7eQoxLE0Ou-sLI,63
|
2
|
-
msad/ad.py,sha256=C3dknAgRY6Jnotk0RgPSye7uzNxUd-7B3oGloGR674E,5679
|
3
|
-
msad/command_line.py,sha256=6JB_rcYsd7UKVgUWmIdMJxMJ5oKmE_KshX7WYe5b5P0,8500
|
4
|
-
msad/group.py,sha256=TKIDEzQEBugux2imt81YX1byRtz31W_qZRXADfoHSbU,3448
|
5
|
-
msad/search.py,sha256=sQfvaaWX6yMKNk5nCPH6yQb2_54LjaEJuXc8FGOhrIo,3383
|
6
|
-
msad/user.py,sha256=oWpoAHcgTMsFf3RXr0wSECONZkJfsIXC3EZTx16BqiY,4466
|
7
|
-
msad-0.3.5.dist-info/METADATA,sha256=1NboA4x0TsN2drxFZjyQpBRT-Y7lyQoJrM37dtN0-tg,3135
|
8
|
-
msad-0.3.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
9
|
-
msad-0.3.5.dist-info/entry_points.txt,sha256=s9wIVNQKKBX5Y6KaYj0STVIZ66mBsseHaamXjshIw7Y,48
|
10
|
-
msad-0.3.5.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
11
|
-
msad-0.3.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|