libre-devops-helpers 0.4.1__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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,261 @@
1
+ """The ``[servicenow]`` config section: named instances, and how to sign in to each.
2
+
3
+ Each profile is one instance and one account on it. Sign-in is OAuth by default, through
4
+ an OAuth application registry entry on the instance: in a browser (single sign-on and
5
+ MFA included, and headless too, by pasting back the address the browser lands on), or
6
+ with a password once. Either way the refresh token is kept, so later commands sign in by
7
+ themselves. ``basic`` (the password with every request) is there for instances that allow
8
+ it.
9
+
10
+ Passwords and client secrets never go in the file. They come from environment variables
11
+ (``SNOW_INSTANCE_PASSWORD`` and ``SNOW_CLIENT_SECRET`` unless a profile names others), or
12
+ are asked for, hidden, when you sign in. Without a ``[servicenow]`` section,
13
+ ``SNOW_INSTANCE_URL`` makes a profile called ``env``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ from collections.abc import Mapping
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ from libre_devops_helpers.core import brand
27
+ from libre_devops_helpers.core.config import (
28
+ ConfigFile,
29
+ check_name,
30
+ load_config_file,
31
+ reject_unknown,
32
+ table,
33
+ text,
34
+ )
35
+ from libre_devops_helpers.core.errors import ConfigError
36
+ from libre_devops_helpers.core.token_store import DEFAULT_TOKEN_CACHE, TOKEN_CACHES
37
+
38
+ SECTION = "servicenow"
39
+ ENV_PROFILE = "env"
40
+ URL_ENV = "SNOW_INSTANCE_URL"
41
+ USERNAME_ENV = "SNOW_INSTANCE_USERNAME"
42
+ PASSWORD_ENV = "SNOW_INSTANCE_PASSWORD"
43
+ CLIENT_ID_ENV = "SNOW_CLIENT_ID"
44
+ CLIENT_SECRET_ENV = "SNOW_CLIENT_SECRET"
45
+ PLACEHOLDER_HOST = "dev00000.service-now.com"
46
+ # Where the browser is sent after an OAuth sign-in: set it as the application registry
47
+ # entry's Redirect URL. Nothing needs to listen there; you paste the address back.
48
+ DEFAULT_REDIRECT_URI = "http://localhost:8765/callback"
49
+
50
+ # oauth: a token and a kept refresh token, through an OAuth application registry entry.
51
+ # basic: the username and password with every request.
52
+ AUTH_METHODS = ("oauth", "basic")
53
+ # How an oauth profile signs in when it has no kept refresh token.
54
+ SIGN_INS = ("browser", "password")
55
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
56
+ _INSTANCE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]*$")
57
+ _SECTION_KEYS = frozenset({"default_profile", "profiles"})
58
+ _PROFILE_KEYS = frozenset(
59
+ {
60
+ "description",
61
+ "instance",
62
+ "username",
63
+ "auth",
64
+ "client_id",
65
+ "sign_in",
66
+ "redirect_uri",
67
+ "token_cache",
68
+ "password_env",
69
+ "client_secret_env",
70
+ }
71
+ )
72
+
73
+ CONFIG_TEMPLATE = f"""\
74
+ # ServiceNow instances. Each profile is an instance and the account you sign in with.
75
+ # Passwords and client secrets come from the environment, never this file. Without
76
+ # this section, {URL_ENV} and {USERNAME_ENV} make a profile called "{ENV_PROFILE}".
77
+ # Replace the placeholder instance, then run: {brand.COMMAND} snow whoami
78
+ [servicenow]
79
+ default_profile = "dev"
80
+
81
+ [servicenow.profiles.dev]
82
+ description = "Personal developer instance"
83
+ instance = "https://{PLACEHOLDER_HOST}"
84
+ # The OAuth application registry entry's client id (or {CLIENT_ID_ENV}). Its secret comes
85
+ # from {CLIENT_SECRET_ENV}, or is asked for when you sign in and then kept.
86
+ client_id = "<client id>"
87
+ # sign_in = "browser" the default: a link to open in any browser, where you sign in as
88
+ # usual (single sign-on and MFA too); paste back where it lands
89
+ # sign_in = "password" the username and password, once (from {PASSWORD_ENV}, or asked)
90
+ # username = "admin" needed for sign_in = "password" and auth = "basic"
91
+ # redirect_uri = "{DEFAULT_REDIRECT_URI}" must match the entry's Redirect URL
92
+ # token_cache = "file" where the refresh token is kept: file (the default), keychain
93
+ # or memory
94
+ # auth = "basic" instead of OAuth: the password with every request, where the
95
+ # instance allows it
96
+ # password_env = "{PASSWORD_ENV}" another variable, for a second instance
97
+ # client_secret_env = "{CLIENT_SECRET_ENV}"
98
+ """
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Profile:
103
+ """One ServiceNow instance, and the account to sign in to it with."""
104
+
105
+ name: str
106
+ instance: str
107
+ username: str | None = None # None: read from SNOW_INSTANCE_USERNAME when needed
108
+ auth: str = "oauth"
109
+ client_id: str | None = None # None: read from SNOW_CLIENT_ID when signing in
110
+ sign_in: str = "browser"
111
+ redirect_uri: str = DEFAULT_REDIRECT_URI
112
+ token_cache: str = DEFAULT_TOKEN_CACHE
113
+ password_env: str = PASSWORD_ENV
114
+ client_secret_env: str = CLIENT_SECRET_ENV
115
+ description: str = ""
116
+
117
+ @property
118
+ def host(self) -> str:
119
+ return urlsplit(self.instance).netloc
120
+
121
+ @property
122
+ def has_placeholder(self) -> bool:
123
+ return self.host == PLACEHOLDER_HOST
124
+
125
+ def require_real_instance(self) -> None:
126
+ if self.has_placeholder:
127
+ raise ConfigError(
128
+ f"ServiceNow profile {self.name!r} still has the template's placeholder instance",
129
+ hint="set its instance in the config file",
130
+ )
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class ServiceNowConfig:
135
+ """The parsed ``[servicenow]`` section."""
136
+
137
+ path: Path | None
138
+ profiles: Mapping[str, Profile]
139
+ default_profile: str | None = None
140
+
141
+ def get(self, name: str) -> Profile:
142
+ try:
143
+ return self.profiles[name]
144
+ except KeyError:
145
+ known = ", ".join(sorted(self.profiles)) or "none"
146
+ raise ConfigError(
147
+ f"unknown ServiceNow profile {name!r} (configured: {known})",
148
+ hint=f"edit {self.path}" if self.path else None,
149
+ ) from None
150
+
151
+
152
+ def instance_url(value: str, where: str) -> str:
153
+ """``https://<name>.service-now.com``, or another https URL, without a trailing slash.
154
+
155
+ A bare instance name (``dev12345``) means ``https://dev12345.service-now.com``.
156
+ """
157
+ value = value.strip()
158
+ if _INSTANCE_NAME.match(value):
159
+ return f"https://{value}.service-now.com"
160
+ parts = urlsplit(value)
161
+ # The password or token goes with every request, so plain http is never acceptable.
162
+ if parts.scheme != "https" or not parts.netloc:
163
+ raise ConfigError(f"{where}: instance must be an https:// URL or an instance name")
164
+ if parts.path.strip("/") or parts.query or parts.fragment:
165
+ raise ConfigError(f"{where}: instance must be the instance's address, with no path")
166
+ return f"https://{parts.netloc.lower()}"
167
+
168
+
169
+ def load_config(path: Path | None = None) -> ServiceNowConfig | None:
170
+ """Read the config file's ``[servicenow]`` section, or None when it has none."""
171
+ return from_file(load_config_file(path))
172
+
173
+
174
+ def from_file(file: ConfigFile) -> ServiceNowConfig | None:
175
+ """Validate the ``[servicenow]`` section; None when the file has none."""
176
+ section = file.section(SECTION)
177
+ if section is None:
178
+ return None
179
+ where = f"{file.path}: [{SECTION}]"
180
+ reject_unknown(section, _SECTION_KEYS, where)
181
+ profiles = {
182
+ name: _parse_profile(name, value, str(file.path))
183
+ for name, value in table(section.get("profiles", {}), f"{where}.profiles").items()
184
+ }
185
+ default = text(section, "default_profile", where)
186
+ if default is not None and default not in profiles:
187
+ raise ConfigError(f"{where}: default_profile {default!r} is not a configured profile")
188
+ return ServiceNowConfig(path=file.path, profiles=profiles, default_profile=default)
189
+
190
+
191
+ def profile_from_env(environ: Mapping[str, str] = os.environ) -> Profile | None:
192
+ """The ``env`` profile from ``SNOW_INSTANCE_URL`` (and friends), or None without it."""
193
+ url = environ.get(URL_ENV, "").strip()
194
+ if not url:
195
+ return None
196
+ # OAuth once there is an application to sign in through; with a password to hand,
197
+ # it signs in with that rather than a browser.
198
+ return Profile(
199
+ name=ENV_PROFILE,
200
+ instance=instance_url(url, URL_ENV),
201
+ username=environ.get(USERNAME_ENV, "").strip() or None,
202
+ auth="oauth" if environ.get(CLIENT_ID_ENV, "").strip() else "basic",
203
+ sign_in="password" if environ.get(PASSWORD_ENV) else "browser",
204
+ description=f"from {URL_ENV}",
205
+ )
206
+
207
+
208
+ def _parse_profile(name: str, value: object, path: str) -> Profile:
209
+ where = f"{path}: [{SECTION}.profiles.{name}]"
210
+ check_name(name, where)
211
+ data = table(value, where)
212
+ reject_unknown(data, _PROFILE_KEYS, where)
213
+ instance = text(data, "instance", where)
214
+ if not instance:
215
+ raise ConfigError(f"{where}: instance is required")
216
+ auth = text(data, "auth", where) or "oauth"
217
+ if auth not in AUTH_METHODS:
218
+ raise ConfigError(f"{where}: auth must be one of {', '.join(AUTH_METHODS)}")
219
+ sign_in = text(data, "sign_in", where)
220
+ if sign_in is not None and sign_in not in SIGN_INS:
221
+ raise ConfigError(f"{where}: sign_in must be one of {', '.join(SIGN_INS)}")
222
+ redirect = text(data, "redirect_uri", where)
223
+ if redirect is not None and not _redirect_ok(redirect):
224
+ raise ConfigError(f"{where}: redirect_uri must be http://localhost... or an https:// URL")
225
+ if auth == "basic" and (sign_in or redirect):
226
+ raise ConfigError(f'{where}: sign_in and redirect_uri apply to auth = "oauth"')
227
+ token_cache = text(data, "token_cache", where)
228
+ if token_cache is not None and token_cache not in TOKEN_CACHES:
229
+ raise ConfigError(f"{where}: token_cache must be one of {', '.join(TOKEN_CACHES)}")
230
+ if token_cache is not None and auth != "oauth":
231
+ raise ConfigError(
232
+ f'{where}: token_cache applies to auth = "oauth"',
233
+ hint="basic sign-in sends the password each time, so there is nothing to keep",
234
+ )
235
+ return Profile(
236
+ name=name,
237
+ instance=instance_url(instance, where),
238
+ username=text(data, "username", where) or None,
239
+ auth=auth,
240
+ client_id=text(data, "client_id", where) or None,
241
+ sign_in=sign_in or "browser",
242
+ redirect_uri=redirect or DEFAULT_REDIRECT_URI,
243
+ token_cache=token_cache or DEFAULT_TOKEN_CACHE,
244
+ password_env=_env_name(data, "password_env", where) or PASSWORD_ENV,
245
+ client_secret_env=_env_name(data, "client_secret_env", where) or CLIENT_SECRET_ENV,
246
+ description=text(data, "description", where) or "",
247
+ )
248
+
249
+
250
+ def _redirect_ok(uri: str) -> bool:
251
+ parts = urlsplit(uri)
252
+ if parts.scheme == "https" and parts.netloc:
253
+ return True
254
+ return parts.scheme == "http" and parts.hostname in {"localhost", "127.0.0.1"}
255
+
256
+
257
+ def _env_name(data: Mapping[str, Any], key: str, where: str) -> str | None:
258
+ value = text(data, key, where)
259
+ if value is not None and not _ENV_NAME.match(value):
260
+ raise ConfigError(f"{where}: {key} must be an environment variable name")
261
+ return value
@@ -0,0 +1,32 @@
1
+ """The instance itself: the signed-in user and roles, the release, and applications.
2
+
3
+ Public API::
4
+
5
+ from libre_devops_helpers.servicenow.instance import InstanceClient
6
+
7
+ InstanceClient(tables).current_user("admin")
8
+ """
9
+
10
+ from libre_devops_helpers.servicenow.instance.client import (
11
+ SIR_PLUGIN,
12
+ SIR_SCOPE,
13
+ SIR_TABLE,
14
+ InstanceClient,
15
+ )
16
+ from libre_devops_helpers.servicenow.instance.models import Application, AppStatus, Release, User
17
+ from libre_devops_helpers.servicenow.roles import RoleRequirement
18
+
19
+ # Reading applications and system properties takes admin on a default instance.
20
+ REQUIREMENTS = (RoleRequirement("instance release and applications", ("admin",)),)
21
+
22
+ __all__ = [
23
+ "REQUIREMENTS",
24
+ "SIR_PLUGIN",
25
+ "SIR_SCOPE",
26
+ "SIR_TABLE",
27
+ "AppStatus",
28
+ "Application",
29
+ "InstanceClient",
30
+ "Release",
31
+ "User",
32
+ ]
@@ -0,0 +1,91 @@
1
+ """Questions about the instance: who am I, which roles, which release, which applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from libre_devops_helpers.core.errors import ApiError, NotFoundError
6
+ from libre_devops_helpers.servicenow.instance.models import Application, AppStatus, Release, User
7
+ from libre_devops_helpers.servicenow.tables import TableClient, condition
8
+
9
+ # Security Incident Response: its plugin, its application scope and its main table.
10
+ SIR_PLUGIN = "com.snc.security_incident"
11
+ SIR_SCOPE = "sn_si"
12
+ SIR_TABLE = "sn_si_incident"
13
+
14
+
15
+ class InstanceClient:
16
+ """Reads the instance's own records through the Table API."""
17
+
18
+ def __init__(self, tables: TableClient) -> None:
19
+ self.tables = tables
20
+
21
+ def current_user(self, user_name: str | None = None) -> User:
22
+ """The signed-in user, as the instance sees it; or the user called ``user_name``."""
23
+ # gs.getUserID() is one of the functions an encoded query may call, and it names
24
+ # whoever the request is authenticated as, however they signed in.
25
+ query = (
26
+ condition("user_name", user_name) if user_name else "sys_id=javascript:gs.getUserID()"
27
+ )
28
+ record = self.tables.first("sys_user", query=query, fields=User.FIELDS)
29
+ if record is None:
30
+ who = f"named {user_name!r}" if user_name else "for this sign-in"
31
+ raise NotFoundError(f"no ServiceNow user {who}")
32
+ return User.from_record(record)
33
+
34
+ def roles(self, user: User) -> tuple[str, ...]:
35
+ """Every role the user holds, directly or through a group or another role."""
36
+ rows = self.tables.records(
37
+ "sys_user_has_role",
38
+ query=f"{condition('user', user.sys_id)}^state=active",
39
+ fields=("role.name",),
40
+ )
41
+ return tuple(sorted({str(row.get("role.name")) for row in rows if row.get("role.name")}))
42
+
43
+ def release(self) -> Release | None:
44
+ """The release, or None when this account may not read system properties."""
45
+ try:
46
+ rows = self.tables.records(
47
+ "sys_properties",
48
+ query="nameINglide.buildtag.last,glide.war",
49
+ fields=("name", "value"),
50
+ )
51
+ except ApiError as exc:
52
+ if exc.status == 403:
53
+ return None
54
+ raise
55
+ values = {str(row.get("name")): str(row.get("value") or "") for row in rows}
56
+ # glide.buildtag.last is not kept as a record on every release; glide.war is.
57
+ tag = values.get("glide.buildtag.last") or values.get("glide.war")
58
+ return Release.from_build_tag(tag) if tag else None
59
+
60
+ def applications(
61
+ self, search: str | None = None, *, active_only: bool = True
62
+ ) -> list[Application]:
63
+ """Scoped applications, by name or scope, active ones first."""
64
+ query = None
65
+ if search:
66
+ query = f"{condition('name', search, 'LIKE')}^OR{condition('scope', search, 'LIKE')}"
67
+ found = [
68
+ Application.from_record(row)
69
+ for row in self.tables.records("sys_scope", query=query, fields=Application.FIELDS)
70
+ ]
71
+ if active_only:
72
+ found = [app for app in found if app.active]
73
+ return sorted(found, key=lambda app: (not app.active, app.name.casefold()))
74
+
75
+ def table_exists(self, name: str) -> bool:
76
+ return (
77
+ self.tables.first("sys_db_object", query=condition("name", name), fields=("name",))
78
+ is not None
79
+ )
80
+
81
+ def security_incident_response(self) -> AppStatus:
82
+ """Whether Security Incident Response is installed: its table is the proof."""
83
+ name = "Security Incident Response"
84
+ if not self.table_exists(SIR_TABLE):
85
+ return AppStatus(name, False, detail=f"no {SIR_TABLE} table")
86
+ app = self.tables.first(
87
+ "sys_scope", query=condition("scope", SIR_SCOPE), fields=("version", "active")
88
+ )
89
+ if app is not None:
90
+ return AppStatus(name, True, str(app.get("version") or ""), f"application {SIR_SCOPE}")
91
+ return AppStatus(name, True, detail=f"table {SIR_TABLE}")
@@ -0,0 +1,131 @@
1
+ """Records about the instance itself: the signed-in user, the release, and applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ # glide-yokohama-12-18-2024__patch4-06-25-2025 (glide.buildtag.last), or the same with a
11
+ # .zip suffix (glide.war), for example.
12
+ _BUILD_TAG = re.compile(
13
+ r"^glide-(?P<family>[a-z]+)-(?P<date>\d{2}-\d{2}-\d{4})(?:__(?P<patch>[a-z0-9]+))?"
14
+ )
15
+
16
+
17
+ def _flag(value: object) -> bool:
18
+ return str(value).strip().lower() in {"true", "1", "active", "yes"}
19
+
20
+
21
+ def _text(record: Mapping[str, Any], key: str) -> str:
22
+ value = record.get(key)
23
+ return "" if value is None else str(value)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class User:
28
+ """A ``sys_user`` record."""
29
+
30
+ sys_id: str
31
+ user_name: str
32
+ name: str
33
+ email: str
34
+ active: bool
35
+ locked_out: bool
36
+ web_service_only: bool
37
+ last_login: str
38
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
39
+
40
+ FIELDS = (
41
+ "sys_id",
42
+ "user_name",
43
+ "name",
44
+ "email",
45
+ "active",
46
+ "locked_out",
47
+ "web_service_access_only",
48
+ "last_login_time",
49
+ )
50
+
51
+ @classmethod
52
+ def from_record(cls, record: Mapping[str, Any]) -> User:
53
+ return cls(
54
+ sys_id=_text(record, "sys_id"),
55
+ user_name=_text(record, "user_name"),
56
+ name=_text(record, "name"),
57
+ email=_text(record, "email"),
58
+ active=_flag(record.get("active")),
59
+ locked_out=_flag(record.get("locked_out")),
60
+ web_service_only=_flag(record.get("web_service_access_only")),
61
+ last_login=_text(record, "last_login_time"),
62
+ raw=dict(record),
63
+ )
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Release:
68
+ """The instance's release, from its build tag: family, build date and patch."""
69
+
70
+ build_tag: str
71
+ family: str = ""
72
+ build_date: str = ""
73
+ patch: str = ""
74
+
75
+ @classmethod
76
+ def from_build_tag(cls, tag: str) -> Release:
77
+ match = _BUILD_TAG.match(tag.strip())
78
+ if not match:
79
+ return cls(build_tag=tag)
80
+ return cls(
81
+ build_tag=tag,
82
+ family=match["family"].capitalize(),
83
+ build_date=match["date"],
84
+ patch=match["patch"] or "",
85
+ )
86
+
87
+ @property
88
+ def label(self) -> str:
89
+ if not self.family:
90
+ return self.build_tag or "unknown"
91
+ return f"{self.family} {self.patch}".strip()
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Application:
96
+ """A scoped application (``sys_scope``): from the ServiceNow Store, or built here.
97
+
98
+ The plugin tables (``v_plugin``, ``sys_plugins``, ``sys_store_app``) refuse the REST
99
+ API even to admin, but every scoped application, Security Incident Response
100
+ (``sn_si``) among them, is listed in ``sys_scope``, which it may read.
101
+ """
102
+
103
+ scope: str
104
+ name: str
105
+ active: bool
106
+ version: str
107
+ kind: str # "store app" or "custom app"
108
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
109
+
110
+ FIELDS = ("scope", "name", "active", "version", "sys_class_name")
111
+
112
+ @classmethod
113
+ def from_record(cls, record: Mapping[str, Any]) -> Application:
114
+ return cls(
115
+ scope=_text(record, "scope"),
116
+ name=_text(record, "name"),
117
+ active=_flag(record.get("active")),
118
+ version=_text(record, "version"),
119
+ kind="store app" if record.get("sys_class_name") == "sys_store_app" else "custom app",
120
+ raw=dict(record),
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class AppStatus:
126
+ """Whether an application is on the instance, and how we know."""
127
+
128
+ name: str
129
+ installed: bool
130
+ version: str = ""
131
+ detail: str = ""
@@ -0,0 +1,23 @@
1
+ """What each ServiceNow feature needs: roles, since the instance's access controls decide.
2
+
3
+ ``admin`` passes every access control, so it satisfies every requirement.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+
11
+ ADMIN = "admin"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RoleRequirement:
16
+ """``feature`` needs any one of ``any_of`` (or admin)."""
17
+
18
+ feature: str
19
+ any_of: tuple[str, ...]
20
+
21
+ def met_by(self, roles: Iterable[str]) -> bool:
22
+ held = {role.casefold() for role in roles}
23
+ return ADMIN in held or any(role.casefold() in held for role in self.any_of)
@@ -0,0 +1,133 @@
1
+ """The ServiceNow Table API: read records from any table, a page at a time.
2
+
3
+ Every ServiceNow feature reads through this. It turns the instance's errors into ones
4
+ that say what to do: a refused sign-in, a missing role (an ACL), a table whose plugin is
5
+ not installed, or a developer instance that is hibernating and answers with a web page.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable, Iterable, Mapping
12
+ from typing import Any, Self
13
+
14
+ import requests
15
+
16
+ from libre_devops_helpers.core.errors import ApiError, InputError
17
+ from libre_devops_helpers.core.http import ApiClient
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ PAGE_SIZE = 1000
22
+ HIBERNATING_HINT = (
23
+ "the instance answered with a web page, not JSON: a developer instance may be "
24
+ "hibernating (wake it at developer.servicenow.com), or the address is not an instance"
25
+ )
26
+ ACL_HINT = "your account lacks a role (an access control) for this table"
27
+
28
+
29
+ def condition(field: str, value: str, operator: str = "=") -> str:
30
+ """One encoded query condition, refusing values that would change the query.
31
+
32
+ ``^`` separates conditions in an encoded query, so a value holding one could add
33
+ conditions of its own; such values are refused rather than escaped.
34
+ """
35
+ if any(mark in value for mark in ("^", "\n", "\r")):
36
+ raise InputError(f"{field}: {value!r} cannot be used in a ServiceNow query")
37
+ return f"{field}{operator}{value}"
38
+
39
+
40
+ class TableClient:
41
+ """Reads records through ``/api/now/table``, for one instance and one sign-in."""
42
+
43
+ def __init__(self, api: ApiClient) -> None:
44
+ self.api = api
45
+
46
+ @classmethod
47
+ def create(
48
+ cls,
49
+ instance: str,
50
+ authorization: Callable[[], str],
51
+ *,
52
+ scheme: str = "Bearer",
53
+ session: requests.Session | None = None,
54
+ verify: bool | str = True,
55
+ ) -> Self:
56
+ """A client for ``instance``, sending ``scheme`` and ``authorization()`` each time."""
57
+ return cls(
58
+ ApiClient(
59
+ instance,
60
+ authorization,
61
+ name="ServiceNow",
62
+ session=session,
63
+ verify=verify,
64
+ auth_scheme=scheme,
65
+ )
66
+ )
67
+
68
+ def close(self) -> None:
69
+ self.api.close()
70
+
71
+ def records(
72
+ self,
73
+ table: str,
74
+ *,
75
+ query: str | None = None,
76
+ fields: Iterable[str] | None = None,
77
+ limit: int | None = None,
78
+ display_value: bool = False,
79
+ ) -> list[dict[str, Any]]:
80
+ """Records of ``table`` matching the encoded ``query``, up to ``limit``."""
81
+ found: list[dict[str, Any]] = []
82
+ params = {
83
+ "sysparm_exclude_reference_link": "true",
84
+ "sysparm_display_value": "true" if display_value else "false",
85
+ }
86
+ if query:
87
+ params["sysparm_query"] = query
88
+ if fields:
89
+ params["sysparm_fields"] = ",".join(fields)
90
+ while True:
91
+ size = PAGE_SIZE if limit is None else min(PAGE_SIZE, limit - len(found))
92
+ page = self._get(
93
+ f"/api/now/table/{table}",
94
+ {**params, "sysparm_limit": str(size), "sysparm_offset": str(len(found))},
95
+ )
96
+ rows = page.get("result")
97
+ if not isinstance(rows, list):
98
+ raise ApiError(f"ServiceNow: the {table} response has no result list")
99
+ found.extend(row for row in rows if isinstance(row, dict))
100
+ if len(rows) < size or (limit is not None and len(found) >= limit):
101
+ return found
102
+
103
+ def first(
104
+ self, table: str, *, query: str, fields: Iterable[str] | None = None
105
+ ) -> dict[str, Any] | None:
106
+ """The first record matching ``query``, or None."""
107
+ rows = self.records(table, query=query, fields=fields, limit=1)
108
+ return rows[0] if rows else None
109
+
110
+ def _get(self, path: str, params: Mapping[str, str]) -> dict[str, Any]:
111
+ try:
112
+ return self.api.get(path, params=params)
113
+ except ApiError as exc:
114
+ raise _explained(exc) from None
115
+
116
+
117
+ def _explained(exc: ApiError) -> ApiError:
118
+ """``exc`` with a hint for the ways a ServiceNow instance says no."""
119
+ text = str(exc)
120
+ hint = exc.hint
121
+ if exc.status == 401:
122
+ hint = (
123
+ "the instance did not accept the sign-in: check the account and its password or "
124
+ "token. ServiceNow blocks basic sign-in to its APIs for interactive accounts "
125
+ "unless they hold the snc_basic_auth_api_access role"
126
+ )
127
+ elif exc.status == 403:
128
+ hint = ACL_HINT
129
+ elif exc.status == 400 and "invalid table" in text.lower():
130
+ hint = "the table does not exist on this instance: its plugin or app is not installed"
131
+ elif exc.status == 200 and "did not return json" in text.lower():
132
+ hint = HIBERNATING_HINT
133
+ return ApiError(text, status=exc.status, code=exc.code, request_id=exc.request_id, hint=hint)