restad 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.
- restad/__init__.py +5 -0
- restad/__main__.py +25 -0
- restad/app.py +101 -0
- restad/config.py +145 -0
- restad/deps.py +50 -0
- restad/models.py +59 -0
- restad/routers/__init__.py +1 -0
- restad/routers/audit.py +132 -0
- restad/routers/computers.py +83 -0
- restad/routers/groups.py +75 -0
- restad/routers/health.py +30 -0
- restad/routers/ous.py +48 -0
- restad/routers/users.py +113 -0
- restad-0.1.0.dist-info/METADATA +142 -0
- restad-0.1.0.dist-info/RECORD +18 -0
- restad-0.1.0.dist-info/WHEEL +4 -0
- restad-0.1.0.dist-info/entry_points.txt +3 -0
- restad-0.1.0.dist-info/licenses/LICENSE +674 -0
restad/__init__.py
ADDED
restad/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Console entry point: run the restAD API with uvicorn.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
restad # listen on 127.0.0.1:8000
|
|
5
|
+
RESTAD_HOST=0.0.0.0 RESTAD_PORT=9000 restad
|
|
6
|
+
|
|
7
|
+
Host/port are read from RESTAD_HOST / RESTAD_PORT (not the MSAD_* vars, which
|
|
8
|
+
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("RESTAD_HOST", "127.0.0.1")
|
|
20
|
+
port = int(os.environ.get("RESTAD_PORT", "8000"))
|
|
21
|
+
uvicorn.run("restad.app:app", host=host, port=port, factory=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
restad/app.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""FastAPI application factory and lifespan.
|
|
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
|
+
``RESTAD_LAZY_CONNECT=true`` to start anyway (the ``/health`` endpoint still
|
|
9
|
+
works and reports the error; data endpoints return 503 until a connection is
|
|
10
|
+
available).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from msad import connect
|
|
22
|
+
|
|
23
|
+
from .config import ConfigError, resolve_domain_config
|
|
24
|
+
from .routers import audit, computers, groups, health, ous, users
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("restad")
|
|
27
|
+
|
|
28
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _env_flag(name: str, default: bool = False) -> bool:
|
|
32
|
+
value = os.environ.get(name)
|
|
33
|
+
if value is None:
|
|
34
|
+
return default
|
|
35
|
+
return value.strip().lower() in _TRUE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@asynccontextmanager
|
|
39
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
40
|
+
"""Resolve config, open the LDAP connection, and tear it down on exit."""
|
|
41
|
+
lazy = _env_flag("RESTAD_LAZY_CONNECT")
|
|
42
|
+
|
|
43
|
+
config = resolve_domain_config()
|
|
44
|
+
app.state.config = config
|
|
45
|
+
app.state.conn = None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
app.state.conn = connect(config)
|
|
49
|
+
auth = "kerberos" if config.uses_kerberos else config.user
|
|
50
|
+
logger.info("Connected to the directory as %s", auth)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
# MsadConnectionError on bind/connect failure, but also e.g. environment
|
|
53
|
+
# issues raised deeper in ldap3. In lazy mode we start anyway; otherwise
|
|
54
|
+
# startup fails loudly.
|
|
55
|
+
if not lazy:
|
|
56
|
+
raise
|
|
57
|
+
logger.warning("Startup connection failed (lazy mode, continuing): %s", exc)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
conn = app.state.conn
|
|
63
|
+
if conn is not None:
|
|
64
|
+
try:
|
|
65
|
+
conn.unbind()
|
|
66
|
+
except Exception: # best-effort teardown
|
|
67
|
+
logger.debug("unbind on shutdown failed", exc_info=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_app() -> FastAPI:
|
|
71
|
+
"""Build and configure the FastAPI application."""
|
|
72
|
+
app = FastAPI(
|
|
73
|
+
title="restAD",
|
|
74
|
+
version="0.1.0",
|
|
75
|
+
summary="Read-only REST interface over Active Directory (msad).",
|
|
76
|
+
description=(
|
|
77
|
+
"A read-only HTTP API in front of the msad library. All endpoints "
|
|
78
|
+
"perform LDAP searches only; no write operations are exposed."
|
|
79
|
+
),
|
|
80
|
+
lifespan=lifespan,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
app.include_router(health.router)
|
|
84
|
+
app.include_router(users.router)
|
|
85
|
+
app.include_router(computers.router)
|
|
86
|
+
app.include_router(groups.router)
|
|
87
|
+
app.include_router(ous.router)
|
|
88
|
+
app.include_router(audit.router)
|
|
89
|
+
|
|
90
|
+
@app.get("/", tags=["health"], summary="Service metadata")
|
|
91
|
+
def root() -> dict[str, str]:
|
|
92
|
+
return {"service": "restAD", "version": "0.1.0", "docs": "/docs"}
|
|
93
|
+
|
|
94
|
+
return app
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Module-level app for `uvicorn restad.app:app` and ASGI servers.
|
|
98
|
+
app = create_app()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["app", "create_app", "lifespan", "ConfigError"]
|
restad/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Configuration resolution for restAD.
|
|
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
|
restad/deps.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Shared FastAPI dependencies.
|
|
2
|
+
|
|
3
|
+
The LDAP connection and resolved configuration are created once during the
|
|
4
|
+
application lifespan (see :mod:`restad.app`) and stored on ``app.state``. These
|
|
5
|
+
dependencies expose them to the route handlers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
13
|
+
from msad import DomainConfig
|
|
14
|
+
from msad.types import LdapConnection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_config(request: Request) -> DomainConfig:
|
|
18
|
+
"""Return the resolved domain configuration from app state."""
|
|
19
|
+
config: DomainConfig | None = getattr(request.app.state, "config", None)
|
|
20
|
+
if config is None: # pragma: no cover - should not happen after lifespan
|
|
21
|
+
raise HTTPException(
|
|
22
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
23
|
+
detail="Configuration not initialized.",
|
|
24
|
+
)
|
|
25
|
+
return config
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_base(config: Annotated[DomainConfig, Depends(get_config)]) -> str:
|
|
29
|
+
"""Return the configured search base DN."""
|
|
30
|
+
return config.base
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_connection(request: Request) -> LdapConnection:
|
|
34
|
+
"""Return the shared bound LDAP connection from app state.
|
|
35
|
+
|
|
36
|
+
Raises 503 if the connection was never established (e.g. the directory was
|
|
37
|
+
unreachable at startup and lazy connection is disabled).
|
|
38
|
+
"""
|
|
39
|
+
conn: LdapConnection | None = getattr(request.app.state, "conn", None)
|
|
40
|
+
if conn is None:
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
43
|
+
detail="LDAP connection is not available.",
|
|
44
|
+
)
|
|
45
|
+
return conn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ConfigDep = Annotated[DomainConfig, Depends(get_config)]
|
|
49
|
+
BaseDep = Annotated[str, Depends(get_base)]
|
|
50
|
+
ConnDep = Annotated[LdapConnection, Depends(get_connection)]
|
restad/models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pydantic response models (wrappers) for the REST API.
|
|
2
|
+
|
|
3
|
+
LDAP entries are intentionally dynamic: the attributes returned depend on the
|
|
4
|
+
requested attribute list, so each entry is represented as an open mapping
|
|
5
|
+
(``dict[str, Any]``) rather than a rigid model. The wrappers below add a small,
|
|
6
|
+
predictable envelope (counts, the domain queried) around those entries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
#: A single LDAP entry: attribute name -> value(s). Values are whatever ldap3
|
|
16
|
+
#: decoded them to (str, int, datetime, list, ...).
|
|
17
|
+
LdapEntry = dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthResponse(BaseModel):
|
|
21
|
+
"""Result of the LDAP connectivity health check."""
|
|
22
|
+
|
|
23
|
+
ok: bool = Field(description="Whether the bind to the directory succeeded.")
|
|
24
|
+
target: str = Field(description="The server URL, e.g. ldaps://dc:636.")
|
|
25
|
+
auth: str = Field(description="Authentication method used (kerberos or user '<name>').")
|
|
26
|
+
error: str | None = Field(default=None, description="Error message when ok is false.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EntryResponse(BaseModel):
|
|
30
|
+
"""A single LDAP entry (or null when not found)."""
|
|
31
|
+
|
|
32
|
+
entry: LdapEntry | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EntriesResponse(BaseModel):
|
|
36
|
+
"""A list of LDAP entries plus a count."""
|
|
37
|
+
|
|
38
|
+
count: int = Field(description="Number of entries returned.")
|
|
39
|
+
entries: list[LdapEntry] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BoolResponse(BaseModel):
|
|
43
|
+
"""A tri-state boolean result (null means 'not found')."""
|
|
44
|
+
|
|
45
|
+
result: bool | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OsSummaryItem(BaseModel):
|
|
49
|
+
"""One row of the computer-OS summary."""
|
|
50
|
+
|
|
51
|
+
operatingSystem: str
|
|
52
|
+
count: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OsSummaryResponse(BaseModel):
|
|
56
|
+
"""Computer accounts grouped and counted by operating system."""
|
|
57
|
+
|
|
58
|
+
count: int = Field(description="Number of distinct operating systems.")
|
|
59
|
+
items: list[OsSummaryItem] = Field(default_factory=list)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read-only API routers for restAD."""
|
restad/routers/audit.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Read-only audit endpoints: domain info, policy, privileged groups, topology."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
8
|
+
from msad import (
|
|
9
|
+
get_domain_info,
|
|
10
|
+
get_gpo,
|
|
11
|
+
get_password_policy,
|
|
12
|
+
get_password_policy_violations,
|
|
13
|
+
get_privileged_groups,
|
|
14
|
+
list_fine_grained_password_policies,
|
|
15
|
+
list_fsmo_roles,
|
|
16
|
+
list_gpos,
|
|
17
|
+
list_sites,
|
|
18
|
+
list_trusts,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ..deps import BaseDep, ConnDep
|
|
22
|
+
from ..models import EntriesResponse, EntryResponse
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/audit", tags=["audit"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/domain", response_model=EntryResponse, summary="Domain object info")
|
|
28
|
+
def domain_info(conn: ConnDep, base: BaseDep) -> EntryResponse:
|
|
29
|
+
"""Read the domain object (security-relevant settings and metadata)."""
|
|
30
|
+
return EntryResponse(entry=get_domain_info(conn, base))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/password-policy", response_model=EntryResponse, summary="Default password policy")
|
|
34
|
+
def password_policy(conn: ConnDep, base: BaseDep) -> EntryResponse:
|
|
35
|
+
"""Read the default domain password policy."""
|
|
36
|
+
return EntryResponse(entry=get_password_policy(conn, base))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get(
|
|
40
|
+
"/password-policy/violations",
|
|
41
|
+
response_model=EntriesResponse,
|
|
42
|
+
summary="Password policy violations",
|
|
43
|
+
)
|
|
44
|
+
def password_policy_violations(
|
|
45
|
+
conn: ConnDep,
|
|
46
|
+
base: BaseDep,
|
|
47
|
+
include_never_set: Annotated[
|
|
48
|
+
bool, Query(description="Also flag users with pwdLastSet=0")
|
|
49
|
+
] = True,
|
|
50
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
51
|
+
) -> EntriesResponse:
|
|
52
|
+
"""Enabled users whose password violates the domain policy."""
|
|
53
|
+
entries = get_password_policy_violations(
|
|
54
|
+
conn, base, include_never_set=include_never_set, limit=limit
|
|
55
|
+
)
|
|
56
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.get("/privileged-groups", response_model=EntriesResponse, summary="Privileged groups")
|
|
60
|
+
def privileged_groups(
|
|
61
|
+
conn: ConnDep,
|
|
62
|
+
base: BaseDep,
|
|
63
|
+
with_members: Annotated[bool, Query(description="Include each group's members")] = False,
|
|
64
|
+
nested: Annotated[bool, Query(description="Expand nested membership")] = False,
|
|
65
|
+
) -> EntriesResponse:
|
|
66
|
+
"""Report on well-known privileged groups with member counts."""
|
|
67
|
+
entries = get_privileged_groups(conn, base, with_members=with_members, nested=nested)
|
|
68
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/fsmo-roles", response_model=EntriesResponse, summary="FSMO role owners")
|
|
72
|
+
def fsmo_roles(conn: ConnDep, base: BaseDep) -> EntriesResponse:
|
|
73
|
+
"""Report the owner of each of the five FSMO roles."""
|
|
74
|
+
entries = list_fsmo_roles(conn, base)
|
|
75
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/trusts", response_model=EntriesResponse, summary="Domain trusts")
|
|
79
|
+
def trusts(
|
|
80
|
+
conn: ConnDep,
|
|
81
|
+
base: BaseDep,
|
|
82
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
83
|
+
) -> EntriesResponse:
|
|
84
|
+
"""List the domain's trust relationships."""
|
|
85
|
+
entries = list_trusts(conn, base, limit=limit)
|
|
86
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/sites", response_model=EntriesResponse, summary="AD sites")
|
|
90
|
+
def sites(
|
|
91
|
+
conn: ConnDep,
|
|
92
|
+
base: BaseDep,
|
|
93
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
94
|
+
) -> EntriesResponse:
|
|
95
|
+
"""List the AD sites from the Configuration NC."""
|
|
96
|
+
entries = list_sites(conn, base, limit=limit)
|
|
97
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.get("/gpos", response_model=EntriesResponse, summary="Group Policy Objects")
|
|
101
|
+
def gpos(
|
|
102
|
+
conn: ConnDep,
|
|
103
|
+
base: BaseDep,
|
|
104
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
105
|
+
) -> EntriesResponse:
|
|
106
|
+
"""List Group Policy Objects."""
|
|
107
|
+
entries = list_gpos(conn, base, limit=limit)
|
|
108
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.get("/gpos/{identifier}", response_model=EntryResponse, summary="Get a single GPO")
|
|
112
|
+
def gpo(conn: ConnDep, base: BaseDep, identifier: str) -> EntryResponse:
|
|
113
|
+
"""Return a single GPO matched by its cn (GUID) or displayName."""
|
|
114
|
+
entry = get_gpo(conn, base, identifier)
|
|
115
|
+
if entry is None:
|
|
116
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="GPO not found")
|
|
117
|
+
return EntryResponse(entry=entry)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.get(
|
|
121
|
+
"/fine-grained-password-policies",
|
|
122
|
+
response_model=EntriesResponse,
|
|
123
|
+
summary="Fine-grained password policies (PSOs)",
|
|
124
|
+
)
|
|
125
|
+
def fine_grained_password_policies(
|
|
126
|
+
conn: ConnDep,
|
|
127
|
+
base: BaseDep,
|
|
128
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
129
|
+
) -> EntriesResponse:
|
|
130
|
+
"""List fine-grained password policies (PSOs)."""
|
|
131
|
+
entries = list_fine_grained_password_policies(conn, base, limit=limit)
|
|
132
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Read-only computer endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
8
|
+
from msad import (
|
|
9
|
+
find_computers,
|
|
10
|
+
find_stale_computers,
|
|
11
|
+
get_computer,
|
|
12
|
+
list_domain_controllers,
|
|
13
|
+
summarize_computer_os,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ..deps import BaseDep, ConnDep
|
|
17
|
+
from ..models import EntriesResponse, EntryResponse, OsSummaryResponse
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/computers", tags=["computers"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("", response_model=EntriesResponse, summary="Find computers by field")
|
|
23
|
+
def list_computers(
|
|
24
|
+
conn: ConnDep,
|
|
25
|
+
base: BaseDep,
|
|
26
|
+
name: Annotated[str | None, Query(description="cn (computer name); * wildcards ok")] = None,
|
|
27
|
+
dns: Annotated[str | None, Query(description="dNSHostName (FQDN); * wildcards ok")] = None,
|
|
28
|
+
os: Annotated[str | None, Query(description="operatingSystem; * wildcards ok")] = None,
|
|
29
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
30
|
+
) -> EntriesResponse:
|
|
31
|
+
"""Find computers matching one or more fields (all ANDed together)."""
|
|
32
|
+
entries = find_computers(conn, base, name=name, dns=dns, os=os, limit=limit)
|
|
33
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/stale", response_model=EntriesResponse, summary="Find stale computers")
|
|
37
|
+
def stale_computers(
|
|
38
|
+
conn: ConnDep,
|
|
39
|
+
base: BaseDep,
|
|
40
|
+
days: Annotated[int, Query(ge=0, description="Staleness threshold in days")] = 90,
|
|
41
|
+
include_never: Annotated[
|
|
42
|
+
bool, Query(description="Also include never-logged-on computers")
|
|
43
|
+
] = False,
|
|
44
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
45
|
+
) -> EntriesResponse:
|
|
46
|
+
"""Computers whose last logon is older than ``days`` days."""
|
|
47
|
+
entries = find_stale_computers(conn, base, days=days, include_never=include_never, limit=limit)
|
|
48
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get(
|
|
52
|
+
"/domain-controllers",
|
|
53
|
+
response_model=EntriesResponse,
|
|
54
|
+
summary="List domain controllers",
|
|
55
|
+
)
|
|
56
|
+
def domain_controllers(
|
|
57
|
+
conn: ConnDep,
|
|
58
|
+
base: BaseDep,
|
|
59
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
60
|
+
) -> EntriesResponse:
|
|
61
|
+
"""List the domain controllers (DC computer accounts)."""
|
|
62
|
+
entries = list_domain_controllers(conn, base, limit=limit)
|
|
63
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/os-summary", response_model=OsSummaryResponse, summary="Computer OS summary")
|
|
67
|
+
def os_summary(
|
|
68
|
+
conn: ConnDep,
|
|
69
|
+
base: BaseDep,
|
|
70
|
+
limit: Annotated[int, Query(ge=0, description="Max computers to scan (0 = no limit)")] = 0,
|
|
71
|
+
) -> OsSummaryResponse:
|
|
72
|
+
"""Summarize computer accounts grouped by operatingSystem."""
|
|
73
|
+
items = summarize_computer_os(conn, base, limit=limit)
|
|
74
|
+
return OsSummaryResponse(count=len(items), items=items)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/{identifier}", response_model=EntryResponse, summary="Get a single computer")
|
|
78
|
+
def read_computer(conn: ConnDep, base: BaseDep, identifier: str) -> EntryResponse:
|
|
79
|
+
"""Return a single computer matched by sAMAccountName, cn or dNSHostName."""
|
|
80
|
+
entry = get_computer(conn, base, identifier)
|
|
81
|
+
if entry is None:
|
|
82
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Computer not found")
|
|
83
|
+
return EntryResponse(entry=entry)
|
restad/routers/groups.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Read-only group endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, status
|
|
8
|
+
from msad import find_groups, get_group, is_member, list_empty_groups
|
|
9
|
+
from msad.exceptions import MsadNotFoundError
|
|
10
|
+
from msad.group import group_members
|
|
11
|
+
|
|
12
|
+
from ..deps import BaseDep, ConnDep
|
|
13
|
+
from ..models import BoolResponse, EntriesResponse, EntryResponse
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/groups", tags=["groups"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("", response_model=EntriesResponse, summary="Search groups")
|
|
19
|
+
def list_groups(
|
|
20
|
+
conn: ConnDep,
|
|
21
|
+
base: BaseDep,
|
|
22
|
+
q: Annotated[
|
|
23
|
+
str, Query(description="cn/name/sAMAccountName/displayName; * wildcards ok")
|
|
24
|
+
] = "*",
|
|
25
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
26
|
+
) -> EntriesResponse:
|
|
27
|
+
"""Search groups by cn, name, sAMAccountName or displayName."""
|
|
28
|
+
entries = find_groups(conn, base, q, limit=limit)
|
|
29
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/empty", response_model=EntriesResponse, summary="List empty groups")
|
|
33
|
+
def empty_groups(
|
|
34
|
+
conn: ConnDep,
|
|
35
|
+
base: BaseDep,
|
|
36
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
37
|
+
) -> EntriesResponse:
|
|
38
|
+
"""List groups that have no direct members."""
|
|
39
|
+
entries = list_empty_groups(conn, base, limit=limit)
|
|
40
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/{identifier}", response_model=EntryResponse, summary="Get a single group")
|
|
44
|
+
def read_group(conn: ConnDep, base: BaseDep, identifier: str) -> EntryResponse:
|
|
45
|
+
"""Return a single group matched by sAMAccountName or cn."""
|
|
46
|
+
entry = get_group(conn, base, identifier)
|
|
47
|
+
if entry is None:
|
|
48
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
|
|
49
|
+
return EntryResponse(entry=entry)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get("/{identifier}/members", response_model=EntriesResponse, summary="Group members")
|
|
53
|
+
def read_group_members(
|
|
54
|
+
conn: ConnDep,
|
|
55
|
+
base: BaseDep,
|
|
56
|
+
identifier: str,
|
|
57
|
+
nested: Annotated[bool, Query(description="Expand nested membership")] = False,
|
|
58
|
+
limit: Annotated[int, Query(ge=0, description="Max entries (0 = no limit)")] = 0,
|
|
59
|
+
) -> EntriesResponse:
|
|
60
|
+
"""Return the members of a group as person objects."""
|
|
61
|
+
try:
|
|
62
|
+
entries = group_members(conn, base, identifier, nested=nested, limit=limit)
|
|
63
|
+
except MsadNotFoundError as exc:
|
|
64
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
65
|
+
return EntriesResponse(count=len(entries), entries=entries)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/{identifier}/members/{user}", response_model=BoolResponse, summary="Membership check")
|
|
69
|
+
def read_is_member(conn: ConnDep, base: BaseDep, identifier: str, user: str) -> BoolResponse:
|
|
70
|
+
"""Check whether ``user`` is a (possibly nested) member of the group."""
|
|
71
|
+
try:
|
|
72
|
+
result = is_member(conn, base, group=identifier, user=user)
|
|
73
|
+
except MsadNotFoundError as exc:
|
|
74
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
75
|
+
return BoolResponse(result=result)
|