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 ADDED
@@ -0,0 +1,5 @@
1
+ """restAD: a read-only REST interface over the msad Active Directory library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
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."""
@@ -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)
@@ -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)