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 +7 -0
- graphqlad/__main__.py +25 -0
- graphqlad/app.py +124 -0
- graphqlad/config.py +145 -0
- graphqlad/context.py +59 -0
- graphqlad/schema.py +321 -0
- graphqlad/types.py +87 -0
- graphqlad-0.1.0.dist-info/METADATA +197 -0
- graphqlad-0.1.0.dist-info/RECORD +12 -0
- graphqlad-0.1.0.dist-info/WHEEL +4 -0
- graphqlad-0.1.0.dist-info/entry_points.txt +3 -0
- graphqlad-0.1.0.dist-info/licenses/LICENSE +674 -0
graphqlad/__init__.py
ADDED
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"]
|