graphqlad 0.1.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.
graphqlad/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """graphqlAD: a read-only GraphQL interface over the msad library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.1.0"
graphqlad/__main__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Console entry point: run the graphqlAD API with uvicorn.
2
+
3
+ Usage:
4
+ graphqlad # listen on 127.0.0.1:8000
5
+ GRAPHQLAD_HOST=0.0.0.0 GRAPHQLAD_PORT=9000 graphqlad
6
+
7
+ Host/port are read from GRAPHQLAD_HOST / GRAPHQLAD_PORT (not the MSAD_* vars,
8
+ which configure the directory connection).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+
15
+
16
+ def main() -> None:
17
+ import uvicorn
18
+
19
+ host = os.environ.get("GRAPHQLAD_HOST", "127.0.0.1")
20
+ port = int(os.environ.get("GRAPHQLAD_PORT", "8000"))
21
+ uvicorn.run("graphqlad.app:app", host=host, port=port, factory=False)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
graphqlad/app.py ADDED
@@ -0,0 +1,124 @@
1
+ """FastAPI application factory and lifespan for graphqlAD.
2
+
3
+ At startup the lifespan resolves the effective :class:`msad.DomainConfig`
4
+ (TOML + ``MSAD_*`` env overrides), opens a single bound LDAP connection and
5
+ stores both on ``app.state``. At shutdown it unbinds the connection.
6
+
7
+ By default a failure to connect at startup is fatal. Set
8
+ ``GRAPHQLAD_LAZY_CONNECT=true`` to start anyway (the ``health`` query still
9
+ works and reports the error; data queries return a GraphQL error until a
10
+ connection is available).
11
+
12
+ The GraphiQL in-browser IDE is served at ``/graphql`` by default; disable it
13
+ with ``GRAPHQLAD_GRAPHIQL=false``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from collections.abc import AsyncIterator
21
+ from contextlib import asynccontextmanager
22
+
23
+ from fastapi import FastAPI, Request
24
+ from msad import connect
25
+ from strawberry.fastapi import GraphQLRouter
26
+
27
+ from .context import Context
28
+ from .schema import schema
29
+
30
+ logger = logging.getLogger("graphqlad")
31
+
32
+ _TRUE = {"1", "true", "yes", "on"}
33
+
34
+
35
+ def _env_flag(name: str, default: bool = False) -> bool:
36
+ value = os.environ.get(name)
37
+ if value is None:
38
+ return default
39
+ return value.strip().lower() in _TRUE
40
+
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
44
+ """Resolve config, open the LDAP connection, and tear it down on exit."""
45
+ # Imported lazily so importing the module never triggers config resolution.
46
+ from .config import resolve_domain_config
47
+
48
+ lazy = _env_flag("GRAPHQLAD_LAZY_CONNECT")
49
+
50
+ config = resolve_domain_config()
51
+ app.state.config = config
52
+ app.state.conn = None
53
+
54
+ try:
55
+ app.state.conn = connect(config)
56
+ auth = "kerberos" if config.uses_kerberos else config.user
57
+ logger.info("Connected to the directory as %s", auth)
58
+ except Exception as exc:
59
+ # MsadConnectionError on bind/connect failure, but also e.g. environment
60
+ # issues raised deeper in ldap3. In lazy mode we start anyway; otherwise
61
+ # startup fails loudly.
62
+ if not lazy:
63
+ raise
64
+ logger.warning("Startup connection failed (lazy mode, continuing): %s", exc)
65
+
66
+ try:
67
+ yield
68
+ finally:
69
+ conn = app.state.conn
70
+ if conn is not None:
71
+ try:
72
+ conn.unbind()
73
+ except Exception: # best-effort teardown
74
+ logger.debug("unbind on shutdown failed", exc_info=True)
75
+
76
+
77
+ async def get_context(request: Request) -> Context:
78
+ """Build the per-request GraphQL context from app.state."""
79
+ return Context(
80
+ config=getattr(request.app.state, "config", None),
81
+ conn=getattr(request.app.state, "conn", None),
82
+ )
83
+
84
+
85
+ def create_app() -> FastAPI:
86
+ """Build and configure the FastAPI application.
87
+
88
+ GraphiQL (the in-browser GraphQL IDE at ``/graphql``) is enabled by
89
+ default. Set ``GRAPHQLAD_GRAPHIQL=false`` to disable it (the GraphQL
90
+ endpoint itself keeps serving POST queries).
91
+ """
92
+ graphiql = _env_flag("GRAPHQLAD_GRAPHIQL", default=True)
93
+
94
+ app = FastAPI(
95
+ title="graphqlAD",
96
+ version="0.1.0",
97
+ summary="Read-only GraphQL interface over Active Directory (msad).",
98
+ lifespan=lifespan,
99
+ )
100
+
101
+ graphql_app: GraphQLRouter = GraphQLRouter(
102
+ schema,
103
+ context_getter=get_context,
104
+ graphql_ide="graphiql" if graphiql else None,
105
+ )
106
+ app.include_router(graphql_app, prefix="/graphql")
107
+
108
+ @app.get("/", tags=["meta"], summary="Service metadata")
109
+ def root() -> dict[str, str]:
110
+ return {
111
+ "service": "graphqlad",
112
+ "version": "0.1.0",
113
+ "graphql": "/graphql",
114
+ "graphiql": "/graphql" if graphiql else "disabled",
115
+ }
116
+
117
+ return app
118
+
119
+
120
+ # Module-level app for `uvicorn graphqlad.app:app` and ASGI servers.
121
+ app = create_app()
122
+
123
+
124
+ __all__ = ["app", "create_app", "lifespan", "get_context"]
graphqlad/config.py ADDED
@@ -0,0 +1,145 @@
1
+ """Configuration resolution for graphqlAD.
2
+
3
+ The effective :class:`msad.DomainConfig` is built in two layers:
4
+
5
+ 1. Optionally load an msad TOML config file (``~/.msad.toml`` by default, or
6
+ the path in ``MSAD_CONFIG_FILE``) and pick a domain.
7
+ 2. Apply ``MSAD_*`` environment-variable overrides on top. Environment values
8
+ always win, so credentials (and any connection parameter) can be injected
9
+ at runtime without ever being written to the TOML file.
10
+
11
+ Supported environment variables
12
+ --------------------------------
13
+ ========================= =================================================
14
+ Variable Effect
15
+ ========================= =================================================
16
+ ``MSAD_CONFIG_FILE`` Path to the msad TOML file (default ``~/.msad.toml``).
17
+ ``MSAD_DOMAIN`` Which ``[domains.<name>]`` section to use.
18
+ ``MSAD_HOST`` Override ``host``.
19
+ ``MSAD_BASE`` Override ``base`` (search base DN).
20
+ ``MSAD_PORT`` Override ``port`` (int).
21
+ ``MSAD_USE_SSL`` Override ``use_ssl`` (true/false/1/0/yes/no).
22
+ ``MSAD_USER`` Override bind ``user`` (enables user/password bind).
23
+ ``MSAD_PASSWORD`` Override bind ``password``.
24
+ ``MSAD_QUERY_TIMEOUT`` Override ``query_timeout`` (int seconds).
25
+ ========================= =================================================
26
+
27
+ If no TOML file exists, the configuration is built entirely from the
28
+ environment: at minimum ``MSAD_HOST`` and ``MSAD_BASE`` must be set.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from pathlib import Path
35
+
36
+ from msad import DomainConfig, MsadConfigError, load_config
37
+
38
+ #: Env var holding the path to the msad TOML config file.
39
+ ENV_CONFIG_FILE = "MSAD_CONFIG_FILE"
40
+ #: Env var selecting which domain section to use.
41
+ ENV_DOMAIN = "MSAD_DOMAIN"
42
+
43
+ _TRUE = {"1", "true", "yes", "on"}
44
+ _FALSE = {"0", "false", "no", "off"}
45
+
46
+
47
+ class ConfigError(RuntimeError):
48
+ """Raised when the effective configuration cannot be resolved."""
49
+
50
+
51
+ def _parse_bool(value: str, name: str) -> bool:
52
+ lowered = value.strip().lower()
53
+ if lowered in _TRUE:
54
+ return True
55
+ if lowered in _FALSE:
56
+ return False
57
+ raise ConfigError(f"{name}: expected a boolean, got {value!r}")
58
+
59
+
60
+ def _parse_int(value: str, name: str) -> int:
61
+ try:
62
+ return int(value.strip())
63
+ except ValueError as exc:
64
+ raise ConfigError(f"{name}: expected an integer, got {value!r}") from exc
65
+
66
+
67
+ def _base_from_toml(env: dict[str, str]) -> dict[str, object]:
68
+ """Load the TOML file (if any) and return the chosen domain as a dict.
69
+
70
+ Returns an empty dict when no config file is present, so the configuration
71
+ can be built purely from environment variables.
72
+ """
73
+ config_file = env.get(ENV_CONFIG_FILE)
74
+ domain = env.get(ENV_DOMAIN)
75
+
76
+ path = Path(config_file) if config_file else None
77
+ # Only attempt to load when a file is explicitly given or the default
78
+ # exists; otherwise fall back to a pure-env configuration.
79
+ from msad import DEFAULT_CONFIG_PATH
80
+
81
+ if path is None and DEFAULT_CONFIG_PATH.is_file():
82
+ path = DEFAULT_CONFIG_PATH
83
+
84
+ if path is None:
85
+ return {}
86
+
87
+ try:
88
+ config = load_config(path)
89
+ domain_config = config.get_domain(domain)
90
+ except MsadConfigError as exc:
91
+ raise ConfigError(str(exc)) from exc
92
+
93
+ return domain_config.model_dump()
94
+
95
+
96
+ def _apply_env_overrides(data: dict[str, object], env: dict[str, str]) -> dict[str, object]:
97
+ """Overlay ``MSAD_*`` environment overrides onto a config dict."""
98
+ if (host := env.get("MSAD_HOST")) is not None:
99
+ data["host"] = host
100
+ if (base := env.get("MSAD_BASE")) is not None:
101
+ data["base"] = base
102
+ if (port := env.get("MSAD_PORT")) is not None:
103
+ data["port"] = _parse_int(port, "MSAD_PORT")
104
+ if (use_ssl := env.get("MSAD_USE_SSL")) is not None:
105
+ data["use_ssl"] = _parse_bool(use_ssl, "MSAD_USE_SSL")
106
+ if (user := env.get("MSAD_USER")) is not None:
107
+ data["user"] = user
108
+ if (password := env.get("MSAD_PASSWORD")) is not None:
109
+ data["password"] = password
110
+ if (timeout := env.get("MSAD_QUERY_TIMEOUT")) is not None:
111
+ data["query_timeout"] = _parse_int(timeout, "MSAD_QUERY_TIMEOUT")
112
+ return data
113
+
114
+
115
+ def resolve_domain_config(env: dict[str, str] | None = None) -> DomainConfig:
116
+ """Resolve the effective :class:`msad.DomainConfig`.
117
+
118
+ Args:
119
+ env: environment mapping to read from (defaults to ``os.environ``).
120
+ Injectable for testing.
121
+
122
+ Returns:
123
+ A validated :class:`msad.DomainConfig`.
124
+
125
+ Raises:
126
+ ConfigError: if required fields are missing or values are invalid.
127
+ """
128
+ environ = dict(os.environ if env is None else env)
129
+
130
+ data = _base_from_toml(environ)
131
+ data = _apply_env_overrides(data, environ)
132
+
133
+ missing = [k for k in ("host", "base") if not data.get(k)]
134
+ if missing:
135
+ raise ConfigError(
136
+ "Missing required configuration: "
137
+ + ", ".join(missing)
138
+ + ". Provide them via the msad TOML file or the MSAD_HOST / MSAD_BASE "
139
+ "environment variables."
140
+ )
141
+
142
+ try:
143
+ return DomainConfig.model_validate(data)
144
+ except Exception as exc: # pydantic ValidationError and friends
145
+ raise ConfigError(f"Invalid configuration: {exc}") from exc
graphqlad/context.py ADDED
@@ -0,0 +1,59 @@
1
+ """GraphQL execution context.
2
+
3
+ The LDAP connection and resolved configuration are created once during the
4
+ application lifespan (see :mod:`graphqlad.app`) and stored on ``app.state``.
5
+ The context getter exposes them to resolvers, mirroring restAD's FastAPI
6
+ dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from msad import DomainConfig
12
+ from msad.types import LdapConnection
13
+ from strawberry.fastapi import BaseContext
14
+
15
+
16
+ class ConnectionUnavailableError(RuntimeError):
17
+ """Raised by resolvers when no bound LDAP connection is available.
18
+
19
+ Happens when the directory was unreachable at startup and lazy-connect is
20
+ enabled; GraphQL surfaces this as an error entry in the response.
21
+ """
22
+
23
+
24
+ class Context(BaseContext):
25
+ """Per-request GraphQL context.
26
+
27
+ Inherits from strawberry's :class:`BaseContext` (required by
28
+ ``GraphQLRouter``). ``conn`` is ``None`` when the directory could not be
29
+ reached at startup (lazy mode); ``config`` is always present once the
30
+ lifespan has run.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ config: DomainConfig | None = None,
36
+ conn: LdapConnection | None = None,
37
+ ) -> None:
38
+ super().__init__()
39
+ self.config = config
40
+ self.conn = conn
41
+
42
+ def require_conn(self) -> LdapConnection:
43
+ """Return the bound connection or raise if it is unavailable."""
44
+ if self.conn is None:
45
+ raise ConnectionUnavailableError(
46
+ "LDAP connection is not available (directory unreachable at startup)."
47
+ )
48
+ return self.conn
49
+
50
+ def require_config(self) -> DomainConfig:
51
+ """Return the resolved config or raise if it is missing."""
52
+ if self.config is None: # pragma: no cover - should not happen post-lifespan
53
+ raise ConnectionUnavailableError("Configuration not initialized.")
54
+ return self.config
55
+
56
+ @property
57
+ def base(self) -> str:
58
+ """The configured search base DN."""
59
+ return self.require_config().base
graphqlad/schema.py ADDED
@@ -0,0 +1,321 @@
1
+ """GraphQL schema: a single read-only ``Query`` type.
2
+
3
+ Every field maps to an msad read function. There is intentionally **no
4
+ Mutation type**: all msad write operations (add/remove member, change
5
+ password, unlock account) are out of scope, so the API is read-only by
6
+ construction.
7
+
8
+ The shared LDAP connection and search base come from the request
9
+ :class:`~graphqlad.context.Context`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import strawberry
15
+ from msad import (
16
+ check_connection,
17
+ find_computers,
18
+ find_groups,
19
+ find_inactive_users,
20
+ find_ous,
21
+ find_stale_computers,
22
+ find_users,
23
+ get_computer,
24
+ get_domain_info,
25
+ get_gpo,
26
+ get_group,
27
+ get_ou,
28
+ get_ou_contents,
29
+ get_password_policy,
30
+ get_password_policy_violations,
31
+ get_privileged_groups,
32
+ get_user,
33
+ is_member,
34
+ list_domain_controllers,
35
+ list_empty_groups,
36
+ list_fine_grained_password_policies,
37
+ list_fsmo_roles,
38
+ list_gpos,
39
+ list_sites,
40
+ list_trusts,
41
+ summarize_computer_os,
42
+ user_groups,
43
+ )
44
+ from msad.exceptions import MsadNotFoundError
45
+ from msad.group import group_members
46
+ from msad.user import (
47
+ has_expired_password,
48
+ has_never_expires_password,
49
+ is_disabled,
50
+ is_locked,
51
+ )
52
+ from strawberry.types import Info
53
+
54
+ from .context import Context
55
+ from .types import Entries, Entry, Health, OsSummary, OsSummaryItem, UserStatus
56
+
57
+
58
+ def _ctx(info: Info) -> Context:
59
+ return info.context
60
+
61
+
62
+ @strawberry.type
63
+ class Query:
64
+ """Read-only queries over Active Directory via msad."""
65
+
66
+ # ----- health -------------------------------------------------------
67
+ @strawberry.field(description="Test connectivity/bind without raising.")
68
+ def health(self, info: Info) -> Health:
69
+ ctx = _ctx(info)
70
+ config = ctx.require_config()
71
+ scheme = "ldaps" if config.use_ssl else "ldap"
72
+ target = f"{scheme}://{config.host}:{config.port}"
73
+ auth = "kerberos" if config.uses_kerberos else f"user {config.user!r}"
74
+ try:
75
+ status = check_connection(config)
76
+ except Exception as exc: # defensive: check_connection should not raise
77
+ return Health(ok=False, target=target, auth=auth, error=str(exc))
78
+ return Health(
79
+ ok=bool(status.get("ok")),
80
+ target=str(status.get("target", target)),
81
+ auth=str(status.get("auth", auth)),
82
+ error=status.get("error"),
83
+ )
84
+
85
+ # ----- users --------------------------------------------------------
86
+ @strawberry.field(description="Find users by field (all ANDed; * wildcards ok).")
87
+ def users(
88
+ self,
89
+ info: Info,
90
+ name: str | None = None,
91
+ surname: str | None = None,
92
+ mail: str | None = None,
93
+ sam: str | None = None,
94
+ department: str | None = None,
95
+ limit: int = 0,
96
+ ) -> Entries:
97
+ ctx = _ctx(info)
98
+ return Entries.of(
99
+ find_users(
100
+ ctx.require_conn(),
101
+ ctx.base,
102
+ name=name,
103
+ surname=surname,
104
+ mail=mail,
105
+ sam=sam,
106
+ department=department,
107
+ limit=limit,
108
+ )
109
+ )
110
+
111
+ @strawberry.field(description="Enabled users whose last logon is older than `days`.")
112
+ def inactive_users(
113
+ self, info: Info, days: int = 90, include_never: bool = False, limit: int = 0
114
+ ) -> Entries:
115
+ ctx = _ctx(info)
116
+ return Entries.of(
117
+ find_inactive_users(
118
+ ctx.require_conn(), ctx.base, days=days, include_never=include_never, limit=limit
119
+ )
120
+ )
121
+
122
+ @strawberry.field(description="Get a single user (sAMAccountName/UPN/mail/cn).")
123
+ def user(self, info: Info, identifier: str) -> Entry:
124
+ ctx = _ctx(info)
125
+ return Entry(entry=get_user(ctx.require_conn(), ctx.base, identifier))
126
+
127
+ @strawberry.field(description="Groups a user belongs to (nested by default).")
128
+ def user_groups(
129
+ self, info: Info, identifier: str, nested: bool = True, limit: int = 0
130
+ ) -> Entries:
131
+ ctx = _ctx(info)
132
+ return Entries.of(
133
+ user_groups(ctx.require_conn(), ctx.base, limit, identifier, nested=nested)
134
+ )
135
+
136
+ @strawberry.field(description="Bundle of boolean status checks for a user.")
137
+ def user_status(self, info: Info, identifier: str) -> UserStatus:
138
+ ctx = _ctx(info)
139
+ conn, base = ctx.require_conn(), ctx.base
140
+ return UserStatus(
141
+ is_disabled=is_disabled(conn, base, identifier),
142
+ is_locked=is_locked(conn, base, identifier),
143
+ has_never_expires_password=has_never_expires_password(conn, base, identifier),
144
+ has_expired_password=has_expired_password(conn, base, identifier),
145
+ )
146
+
147
+ # ----- groups -------------------------------------------------------
148
+ @strawberry.field(description="Search groups by cn/name/sAMAccountName/displayName.")
149
+ def groups(self, info: Info, q: str = "*", limit: int = 0) -> Entries:
150
+ ctx = _ctx(info)
151
+ return Entries.of(find_groups(ctx.require_conn(), ctx.base, q, limit=limit))
152
+
153
+ @strawberry.field(description="Groups with no direct members.")
154
+ def empty_groups(self, info: Info, limit: int = 0) -> Entries:
155
+ ctx = _ctx(info)
156
+ return Entries.of(list_empty_groups(ctx.require_conn(), ctx.base, limit=limit))
157
+
158
+ @strawberry.field(description="Get a single group (sAMAccountName/cn).")
159
+ def group(self, info: Info, identifier: str) -> Entry:
160
+ ctx = _ctx(info)
161
+ return Entry(entry=get_group(ctx.require_conn(), ctx.base, identifier))
162
+
163
+ @strawberry.field(description="Members of a group (direct, or nested).")
164
+ def group_members(
165
+ self, info: Info, identifier: str, nested: bool = False, limit: int = 0
166
+ ) -> Entries:
167
+ ctx = _ctx(info)
168
+ try:
169
+ members = group_members(
170
+ ctx.require_conn(), ctx.base, identifier, nested=nested, limit=limit
171
+ )
172
+ except MsadNotFoundError:
173
+ members = []
174
+ return Entries.of(members)
175
+
176
+ @strawberry.field(description="Is `user` a (nested) member of `group`?")
177
+ def is_member(self, info: Info, group: str, user: str) -> bool | None:
178
+ ctx = _ctx(info)
179
+ try:
180
+ return is_member(ctx.require_conn(), ctx.base, group=group, user=user)
181
+ except MsadNotFoundError:
182
+ return None
183
+
184
+ # ----- computers ----------------------------------------------------
185
+ @strawberry.field(description="Find computers by name/dns/os (* wildcards ok).")
186
+ def computers(
187
+ self,
188
+ info: Info,
189
+ name: str | None = None,
190
+ dns: str | None = None,
191
+ os: str | None = None,
192
+ limit: int = 0,
193
+ ) -> Entries:
194
+ ctx = _ctx(info)
195
+ return Entries.of(
196
+ find_computers(ctx.require_conn(), ctx.base, name=name, dns=dns, os=os, limit=limit)
197
+ )
198
+
199
+ @strawberry.field(description="Computers whose last logon is older than `days`.")
200
+ def stale_computers(
201
+ self, info: Info, days: int = 90, include_never: bool = False, limit: int = 0
202
+ ) -> Entries:
203
+ ctx = _ctx(info)
204
+ return Entries.of(
205
+ find_stale_computers(
206
+ ctx.require_conn(), ctx.base, days=days, include_never=include_never, limit=limit
207
+ )
208
+ )
209
+
210
+ @strawberry.field(description="List domain controllers (DC computer accounts).")
211
+ def domain_controllers(self, info: Info, limit: int = 0) -> Entries:
212
+ ctx = _ctx(info)
213
+ return Entries.of(list_domain_controllers(ctx.require_conn(), ctx.base, limit=limit))
214
+
215
+ @strawberry.field(description="Computer accounts grouped by operatingSystem.")
216
+ def os_summary(self, info: Info, limit: int = 0) -> OsSummary:
217
+ ctx = _ctx(info)
218
+ items = summarize_computer_os(ctx.require_conn(), ctx.base, limit=limit) or []
219
+ rows = [
220
+ OsSummaryItem(
221
+ operating_system=str(it.get("operatingSystem", "")),
222
+ count=int(it.get("count", 0)),
223
+ )
224
+ for it in items
225
+ ]
226
+ return OsSummary(count=len(rows), items=rows)
227
+
228
+ @strawberry.field(description="Get a single computer (sAMAccountName/cn/dNSHostName).")
229
+ def computer(self, info: Info, identifier: str) -> Entry:
230
+ ctx = _ctx(info)
231
+ return Entry(entry=get_computer(ctx.require_conn(), ctx.base, identifier))
232
+
233
+ # ----- OUs ----------------------------------------------------------
234
+ @strawberry.field(description="Find organizational units by name (* wildcards ok).")
235
+ def ous(self, info: Info, name: str | None = None, limit: int = 0) -> Entries:
236
+ ctx = _ctx(info)
237
+ return Entries.of(find_ous(ctx.require_conn(), ctx.base, name=name, limit=limit))
238
+
239
+ @strawberry.field(description="List objects directly under an OU (by its DN).")
240
+ def ou_contents(
241
+ self, info: Info, ou_dn: str, object_class: str | None = None, limit: int = 0
242
+ ) -> Entries:
243
+ ctx = _ctx(info)
244
+ return Entries.of(
245
+ get_ou_contents(ctx.require_conn(), ou_dn, object_class=object_class, limit=limit)
246
+ )
247
+
248
+ @strawberry.field(description="Get a single OU (ou name or full DN).")
249
+ def ou(self, info: Info, identifier: str) -> Entry:
250
+ ctx = _ctx(info)
251
+ return Entry(entry=get_ou(ctx.require_conn(), ctx.base, identifier))
252
+
253
+ # ----- audit --------------------------------------------------------
254
+ @strawberry.field(description="Domain object: security-relevant settings and metadata.")
255
+ def domain_info(self, info: Info) -> Entry:
256
+ ctx = _ctx(info)
257
+ return Entry(entry=get_domain_info(ctx.require_conn(), ctx.base))
258
+
259
+ @strawberry.field(description="Default domain password policy.")
260
+ def password_policy(self, info: Info) -> Entry:
261
+ ctx = _ctx(info)
262
+ return Entry(entry=get_password_policy(ctx.require_conn(), ctx.base))
263
+
264
+ @strawberry.field(description="Enabled users whose password violates the policy.")
265
+ def password_policy_violations(
266
+ self, info: Info, include_never_set: bool = True, limit: int = 0
267
+ ) -> Entries:
268
+ ctx = _ctx(info)
269
+ return Entries.of(
270
+ get_password_policy_violations(
271
+ ctx.require_conn(), ctx.base, include_never_set=include_never_set, limit=limit
272
+ )
273
+ )
274
+
275
+ @strawberry.field(description="Well-known privileged groups with member counts.")
276
+ def privileged_groups(
277
+ self, info: Info, with_members: bool = False, nested: bool = False
278
+ ) -> Entries:
279
+ ctx = _ctx(info)
280
+ return Entries.of(
281
+ get_privileged_groups(
282
+ ctx.require_conn(), ctx.base, with_members=with_members, nested=nested
283
+ )
284
+ )
285
+
286
+ @strawberry.field(description="Owner of each of the five FSMO roles.")
287
+ def fsmo_roles(self, info: Info) -> Entries:
288
+ ctx = _ctx(info)
289
+ return Entries.of(list_fsmo_roles(ctx.require_conn(), ctx.base))
290
+
291
+ @strawberry.field(description="Domain trust relationships.")
292
+ def trusts(self, info: Info, limit: int = 0) -> Entries:
293
+ ctx = _ctx(info)
294
+ return Entries.of(list_trusts(ctx.require_conn(), ctx.base, limit=limit))
295
+
296
+ @strawberry.field(description="AD sites from the Configuration NC.")
297
+ def sites(self, info: Info, limit: int = 0) -> Entries:
298
+ ctx = _ctx(info)
299
+ return Entries.of(list_sites(ctx.require_conn(), ctx.base, limit=limit))
300
+
301
+ @strawberry.field(description="Group Policy Objects.")
302
+ def gpos(self, info: Info, limit: int = 0) -> Entries:
303
+ ctx = _ctx(info)
304
+ return Entries.of(list_gpos(ctx.require_conn(), ctx.base, limit=limit))
305
+
306
+ @strawberry.field(description="Get a single GPO (cn GUID or displayName).")
307
+ def gpo(self, info: Info, identifier: str) -> Entry:
308
+ ctx = _ctx(info)
309
+ return Entry(entry=get_gpo(ctx.require_conn(), ctx.base, identifier))
310
+
311
+ @strawberry.field(description="Fine-grained password policies (PSOs).")
312
+ def fine_grained_password_policies(self, info: Info, limit: int = 0) -> Entries:
313
+ ctx = _ctx(info)
314
+ return Entries.of(
315
+ list_fine_grained_password_policies(ctx.require_conn(), ctx.base, limit=limit)
316
+ )
317
+
318
+
319
+ schema = strawberry.Schema(query=Query)
320
+
321
+ __all__ = ["Query", "schema"]