remote-exec-grpc 0.2.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.
remote_exec/policy.py ADDED
@@ -0,0 +1,420 @@
1
+ """Server-side allow/deny policy for the programs a command may run (tools.yaml).
2
+
3
+ ``tools.yaml``::
4
+
5
+ allow: ["*"] # "*" = every program (glob patterns are supported)
6
+ deny: [ssh] # deny wins over allow
7
+
8
+ Defaults (no file, or a key missing/empty): ``allow: ["*"]``, ``deny: ["ssh"]``.
9
+ ``deny: []`` explicitly denies nothing (e.g. to permit ssh). All values are
10
+ read as strings, so names like ``true`` or ``yes`` stay program names.
11
+
12
+ Commands are shell strings, so the policy looks at every program the command
13
+ line would start: each simple command in pipelines/lists (``|``, ``&&``,
14
+ ``;``, newlines), subshells, ``$(...)`` / backticks / ``<(...)``, the target
15
+ of wrappers such as ``sudo``, ``env``, ``timeout``, ``nohup``, and scripts
16
+ passed to ``bash -c`` / ``eval``. Patterns without "/" match the program's
17
+ base name (``ssh`` matches ``/usr/bin/ssh``).
18
+
19
+ This is a guardrail against mistakes, not a sandbox: programs started
20
+ indirectly (a script or ``python -c`` calling ssh, ``X=ssh; $X``) cannot be
21
+ seen in the command line. Use OS-level controls for real isolation.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import re
29
+ from dataclasses import dataclass
30
+ from fnmatch import fnmatchcase
31
+ from pathlib import Path
32
+
33
+ import yaml
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ DEFAULT_TOOLS_FILE = Path("~/.config/remote-exec/tools.yaml")
38
+ DEFAULT_ALLOW = ("*",)
39
+ DEFAULT_DENY = ("ssh",)
40
+
41
+ # Shell builtins that do not start another program: never need to be allowed
42
+ # (they can still be denied explicitly).
43
+ BUILTINS = frozenset({
44
+ "cd", "echo", "printf", "pwd", "export", "unset", "set", "true", "false", "test", "[", "[[",
45
+ "read", "exit", "return", "shift", "local", "declare", "typeset", "wait", ":", "let", "umask",
46
+ "ulimit", "trap", "type", "hash", "shopt", "alias", "unalias", "pushd", "popd", "dirs",
47
+ "break", "continue", "getopts", "readonly", "jobs",
48
+ })
49
+ # Reserved words that may precede a command.
50
+ _KEYWORD_PREFIXES = frozenset({"!", "{", "}", "if", "then", "else", "elif", "do", "while", "until", "time"})
51
+ # Words starting a construct whose remaining words are not a program.
52
+ _NON_COMMANDS = frozenset({"for", "select", "case", "esac", "fi", "done", "in", "function", "((", "]]"})
53
+ # Commands that run another command given as their arguments.
54
+ WRAPPERS = frozenset({
55
+ "sudo", "doas", "env", "nohup", "time", "exec", "nice", "timeout", "command", "builtin",
56
+ "xargs", "stdbuf", "setsid", "ionice", "chrt", "unbuffer", "watch", "strace", "ltrace",
57
+ "numactl", "srun", "mpirun", "mpiexec",
58
+ })
59
+ # Wrapper options that consume the following word (e.g. ``sudo -u user``).
60
+ _WRAPPER_OPTS_WITH_ARG = {
61
+ "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T", "--user", "--group", "--host",
62
+ "--prompt", "--close-from", "--chdir", "--role", "--type", "--other-user", "--command-timeout"},
63
+ "doas": {"-u", "-C"},
64
+ "env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"},
65
+ "nice": {"-n", "--adjustment"},
66
+ "timeout": {"-s", "-k", "--signal", "--kill-after"},
67
+ "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata"},
68
+ "xargs": {"-I", "-i", "-n", "-P", "-L", "-l", "-d", "-E", "-e", "-s", "-a", "--arg-file", "--delimiter",
69
+ "--max-args", "--max-procs", "--max-lines", "--max-chars", "--replace"},
70
+ "watch": {"-n", "--interval", "-d"},
71
+ "numactl": {"-N", "-m", "-C", "-p", "-i", "--cpunodebind", "--membind", "--physcpubind",
72
+ "--preferred", "--interleave"},
73
+ "srun": {"-n", "-N", "-p", "-c", "-t", "-J", "-A", "-w", "--partition", "--account", "--time"},
74
+ "mpirun": {"-n", "-np", "-N", "-H", "--host", "--hostfile", "-hostfile", "-machinefile", "--map-by"},
75
+ "mpiexec": {"-n", "-np", "-N", "-H", "--host", "--hostfile", "-hostfile", "-machinefile", "--map-by"},
76
+ "strace": {"-o", "-e", "-p", "-s", "-u", "-E"},
77
+ "ltrace": {"-o", "-e", "-p", "-s", "-u", "-E"},
78
+ "chrt": {"-p"},
79
+ }
80
+ SHELLS = frozenset({"sh", "bash", "zsh", "dash", "ksh", "fish"})
81
+
82
+ _ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\+?=")
83
+ _WRAPPER_ARG_RE = re.compile(r"^(\d+(\.\d+)?[smhd]?|0x[0-9a-fA-F]+)$") # durations, priorities, masks
84
+
85
+
86
+ class PolicyError(Exception):
87
+ pass
88
+
89
+
90
+ # ---------------------------------------------------------------- shell parsing
91
+
92
+
93
+ class _Parser:
94
+ """Small bash-like lexer that collects simple commands (lists of words).
95
+
96
+ Expansions it cannot evaluate are kept as a literal ``$`` in the word.
97
+ It is deliberately lenient: malformed input (e.g. an unterminated quote)
98
+ is parsed as far as possible; bash will reject it anyway.
99
+ """
100
+
101
+ def __init__(self, text: str):
102
+ self.s = text
103
+ self.i = 0
104
+ self.commands: list[list[str]] = []
105
+
106
+ def parse(self, until: str | None = None) -> None:
107
+ s = self.s
108
+ words: list[str] = []
109
+ word: list[str] | None = None
110
+ skip_next_word = False # redirection target
111
+ heredocs: list[tuple[str, bool]] = [] # (delimiter, strip tabs)
112
+ pending_heredoc: bool | None = None # next word is a heredoc delimiter
113
+ depth = 0
114
+
115
+ def end_word() -> None:
116
+ nonlocal word, skip_next_word, pending_heredoc
117
+ if word is None:
118
+ return
119
+ value = "".join(word)
120
+ word = None
121
+ if pending_heredoc is not None:
122
+ heredocs.append((value, pending_heredoc))
123
+ pending_heredoc = None
124
+ if skip_next_word:
125
+ skip_next_word = False
126
+ else:
127
+ words.append(value)
128
+
129
+ def end_command() -> None:
130
+ end_word()
131
+ if words:
132
+ self.commands.append(list(words))
133
+ words.clear()
134
+
135
+ def start_word() -> list[str]:
136
+ nonlocal word
137
+ if word is None:
138
+ word = []
139
+ return word
140
+
141
+ while self.i < len(s):
142
+ c = s[self.i]
143
+ if until == "`" and c == "`":
144
+ self.i += 1
145
+ end_command()
146
+ return
147
+ if c in " \t\r":
148
+ end_word()
149
+ self.i += 1
150
+ elif c == "\n":
151
+ end_command()
152
+ self.i += 1
153
+ for delim, strip in heredocs:
154
+ self._skip_heredoc(delim, strip)
155
+ heredocs.clear()
156
+ elif c in ";&|":
157
+ end_command()
158
+ self.i += 1
159
+ elif c == "(":
160
+ end_command()
161
+ depth += 1
162
+ self.i += 1
163
+ elif c == ")":
164
+ end_command()
165
+ self.i += 1
166
+ if until == ")" and depth == 0:
167
+ return
168
+ depth = max(0, depth - 1)
169
+ elif c in "<>":
170
+ if word is not None and "".join(word).isdigit():
171
+ word = None # file descriptor number, e.g. 2>&1
172
+ else:
173
+ end_word()
174
+ if self.s.startswith("(", self.i + 1): # process substitution <(...) >(...)
175
+ self.i += 2
176
+ self.parse(until=")")
177
+ continue
178
+ op_start = self.i
179
+ self.i += 1
180
+ while self.i < len(s) and s[self.i] in "<>&|-":
181
+ self.i += 1
182
+ op = s[op_start:self.i]
183
+ if op in ("<<", "<<-"):
184
+ pending_heredoc = op == "<<-"
185
+ skip_next_word = True
186
+ elif c == "#" and word is None:
187
+ while self.i < len(s) and s[self.i] != "\n":
188
+ self.i += 1
189
+ elif c == "\\":
190
+ if self.s.startswith("\n", self.i + 1):
191
+ self.i += 2 # line continuation
192
+ else:
193
+ start_word().append(s[self.i + 1:self.i + 2])
194
+ self.i += 2
195
+ elif c == "'":
196
+ end = s.find("'", self.i + 1)
197
+ end = len(s) if end < 0 else end
198
+ start_word().extend(s[self.i + 1:end])
199
+ self.i = end + 1
200
+ elif c == '"':
201
+ self.i += 1
202
+ self._double_quoted(start_word())
203
+ elif c == "`":
204
+ self.i += 1
205
+ self.parse(until="`")
206
+ start_word().append("$")
207
+ elif c == "$":
208
+ self._dollar(start_word())
209
+ else:
210
+ start_word().append(c)
211
+ self.i += 1
212
+ end_command()
213
+
214
+ def _double_quoted(self, word: list[str]) -> None:
215
+ s = self.s
216
+ while self.i < len(s):
217
+ c = s[self.i]
218
+ if c == '"':
219
+ self.i += 1
220
+ return
221
+ if c == "\\" and self.i + 1 < len(s):
222
+ word.append(s[self.i + 1])
223
+ self.i += 2
224
+ elif c == "`":
225
+ self.i += 1
226
+ self.parse(until="`")
227
+ word.append("$")
228
+ elif c == "$":
229
+ self._dollar(word)
230
+ else:
231
+ word.append(c)
232
+ self.i += 1
233
+
234
+ def _dollar(self, word: list[str]) -> None:
235
+ s = self.s
236
+ word.append("$")
237
+ if s.startswith("$((", self.i): # arithmetic: no commands inside
238
+ self.i = self._skip_balanced(self.i + 1, "(", ")")
239
+ elif s.startswith("$(", self.i):
240
+ self.i += 2
241
+ self.parse(until=")")
242
+ elif s.startswith("${", self.i):
243
+ self.i = self._skip_balanced(self.i + 1, "{", "}")
244
+ else:
245
+ self.i += 1
246
+
247
+ def _skip_balanced(self, start: int, open_: str, close: str) -> int:
248
+ depth, i = 0, start
249
+ while i < len(self.s):
250
+ if self.s[i] == open_:
251
+ depth += 1
252
+ elif self.s[i] == close:
253
+ depth -= 1
254
+ if depth == 0:
255
+ return i + 1
256
+ i += 1
257
+ return i
258
+
259
+ def _skip_heredoc(self, delim: str, strip_tabs: bool) -> None:
260
+ s = self.s
261
+ while self.i < len(s):
262
+ end = s.find("\n", self.i)
263
+ end = len(s) if end < 0 else end
264
+ line = s[self.i:end]
265
+ self.i = end + 1
266
+ if (line.lstrip("\t") if strip_tabs else line) == delim:
267
+ return
268
+
269
+
270
+ def _programs_in_words(words: list[str], out: list[str], depth: int) -> None:
271
+ """Find the program of one simple command, following wrappers/shells."""
272
+ i = 0
273
+ while i < len(words) and (words[i] in _KEYWORD_PREFIXES or _ASSIGNMENT_RE.match(words[i])):
274
+ i += 1
275
+ if i >= len(words) or words[i] in _NON_COMMANDS:
276
+ return
277
+ program, args = words[i], words[i + 1:]
278
+ out.append(program)
279
+ name = os.path.basename(program)
280
+
281
+ if name in SHELLS:
282
+ for j, arg in enumerate(args):
283
+ if arg.startswith("-") and not arg.startswith("--") and "c" in arg[1:]:
284
+ if j + 1 < len(args):
285
+ _collect(args[j + 1], out, depth + 1)
286
+ return
287
+ return
288
+ if name == "eval":
289
+ _collect(" ".join(args), out, depth + 1)
290
+ return
291
+ if name in WRAPPERS:
292
+ if name == "command" and any(a in ("-v", "-V") for a in args):
293
+ return # only looks the name up
294
+ takes_arg = _WRAPPER_OPTS_WITH_ARG.get(name, set())
295
+ j = 0
296
+ while j < len(args) and (
297
+ args[j].startswith("-") or _ASSIGNMENT_RE.match(args[j]) or _WRAPPER_ARG_RE.match(args[j])
298
+ ):
299
+ j += 2 if args[j] in takes_arg else 1
300
+ if j < len(args):
301
+ _programs_in_words(args[j:], out, depth)
302
+
303
+
304
+ def _collect(command: str, out: list[str], depth: int) -> None:
305
+ if depth > 20:
306
+ raise PolicyError("command is nested too deeply to analyse")
307
+ parser = _Parser(command)
308
+ parser.parse()
309
+ for words in parser.commands:
310
+ _programs_in_words(words, out, depth)
311
+
312
+
313
+ def programs_in(command: str) -> list[str]:
314
+ """Programs (as written, e.g. ``ssh`` or ``/usr/bin/ssh``) that ``command`` would run."""
315
+ out: list[str] = []
316
+ _collect(command, out, 0)
317
+ return out
318
+
319
+
320
+ # ---------------------------------------------------------------- policy
321
+
322
+
323
+ def _matches(program: str, patterns: tuple[str, ...]) -> str | None:
324
+ base = os.path.basename(program)
325
+ for pattern in patterns:
326
+ target = os.path.normpath(program) if "/" in pattern else base
327
+ if fnmatchcase(target, pattern):
328
+ return pattern
329
+ return None
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class ToolPolicy:
334
+ allow: tuple[str, ...] = DEFAULT_ALLOW
335
+ deny: tuple[str, ...] = DEFAULT_DENY
336
+ source: str = "built-in default"
337
+
338
+ def check(self, command: str) -> str | None:
339
+ """Return a reason if ``command`` is not allowed, else None."""
340
+ try:
341
+ programs = programs_in(command)
342
+ except PolicyError as exc:
343
+ return str(exc)
344
+ for program in programs:
345
+ if pattern := _matches(program, self.deny):
346
+ return f"program '{program}' is denied (deny pattern '{pattern}')"
347
+ if os.path.basename(program) in BUILTINS:
348
+ continue
349
+ if not _matches(program, self.allow):
350
+ return f"program '{program}' is not in the allow list"
351
+ return None
352
+
353
+ def describe(self) -> str:
354
+ return f"allow={list(self.allow)} deny={list(self.deny)} ({self.source})"
355
+
356
+
357
+ def _pattern_list(value, key: str, path: Path) -> tuple[str, ...]:
358
+ if isinstance(value, str):
359
+ value = [value]
360
+ if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
361
+ raise PolicyError(f"{path}: '{key}' must be a list of program names or patterns")
362
+ return tuple(v.strip() for v in value)
363
+
364
+
365
+ def load_policy(path: str | os.PathLike) -> ToolPolicy:
366
+ p = Path(path).expanduser()
367
+ try:
368
+ # BaseLoader keeps every scalar a string: program names such as
369
+ # "true", "yes" or "7z" must not turn into booleans/numbers.
370
+ data = yaml.load(p.read_text(), Loader=yaml.BaseLoader)
371
+ except OSError as exc:
372
+ raise PolicyError(f"cannot read tools file {p}: {exc.strerror or exc}") from exc
373
+ except yaml.YAMLError as exc:
374
+ raise PolicyError(f"invalid YAML in {p}: {exc}") from exc
375
+ data = data or {}
376
+ if not isinstance(data, dict):
377
+ raise PolicyError(f"{p}: top level must be a mapping with 'allow' and/or 'deny'")
378
+ unknown = set(data) - {"allow", "deny"}
379
+ if unknown:
380
+ raise PolicyError(f"{p}: unknown key(s): {', '.join(sorted(unknown))}")
381
+ # A missing or empty key keeps its default; an explicit empty list
382
+ # ("deny: []") means "nothing" (e.g. to allow ssh).
383
+ allow = _pattern_list(data["allow"], "allow", p) if data.get("allow") not in (None, "") else DEFAULT_ALLOW
384
+ deny = _pattern_list(data["deny"], "deny", p) if data.get("deny") not in (None, "") else DEFAULT_DENY
385
+ return ToolPolicy(allow=allow, deny=deny, source=str(p))
386
+
387
+
388
+ class PolicyProvider:
389
+ """Serves the current policy, reloading tools.yaml when it changes.
390
+
391
+ If a reload fails (invalid YAML), the last good policy stays active.
392
+ """
393
+
394
+ def __init__(self, path: str | os.PathLike | None = None, required: bool = False):
395
+ self.path = Path(path or DEFAULT_TOOLS_FILE).expanduser()
396
+ self._mtime: float | None = None
397
+ self._policy = ToolPolicy()
398
+ if self.path.exists():
399
+ # Fail fast at startup on an invalid file (later reloads keep the last good policy).
400
+ self._mtime = self.path.stat().st_mtime
401
+ self._policy = load_policy(self.path)
402
+ elif required:
403
+ raise PolicyError(f"tools file not found: {self.path}")
404
+
405
+ def current(self) -> ToolPolicy:
406
+ try:
407
+ mtime = self.path.stat().st_mtime
408
+ except FileNotFoundError:
409
+ if self._mtime is not None:
410
+ log.warning("tools file %s removed; using built-in default policy", self.path)
411
+ self._mtime, self._policy = None, ToolPolicy()
412
+ return self._policy
413
+ if mtime != self._mtime:
414
+ self._mtime = mtime
415
+ try:
416
+ self._policy = load_policy(self.path)
417
+ log.info("tools policy loaded: %s", self._policy.describe())
418
+ except PolicyError as exc:
419
+ log.error("%s; keeping previous policy: %s", exc, self._policy.describe())
420
+ return self._policy
@@ -0,0 +1,178 @@
1
+ """Authentication and transport security helpers shared by server and client.
2
+
3
+ Two independent mechanisms, which can be combined:
4
+
5
+ * Bearer token: a shared secret read from a file, sent by the client as
6
+ ``authorization: Bearer <token>`` metadata and checked in constant time.
7
+ * TLS / mTLS: server certificate, optionally requiring client certificates
8
+ signed by a given CA.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hmac
14
+ import ipaddress
15
+ import logging
16
+ import os
17
+ import secrets
18
+ import stat
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ import grpc
23
+
24
+ log = logging.getLogger(__name__)
25
+ audit = logging.getLogger("remote_exec.audit")
26
+
27
+ AUTH_METADATA_KEY = "authorization"
28
+ BEARER_PREFIX = "Bearer "
29
+ DEFAULT_TOKEN_FILE = Path("~/.config/remote-exec/token")
30
+ TOKEN_BYTES = 32
31
+
32
+
33
+ class SecurityError(Exception):
34
+ pass
35
+
36
+
37
+ def generate_token_file(path: str | os.PathLike | None = None, force: bool = False) -> Path:
38
+ """Write a new random token (hex, 256 bit) to ``path`` with mode 600.
39
+
40
+ The parent directory is created with mode 700 if missing. An existing file
41
+ is only replaced when ``force`` is true, because replacing it locks out
42
+ every client that still has the old token.
43
+ """
44
+ target = Path(path or DEFAULT_TOKEN_FILE).expanduser()
45
+ if target.exists() and not force:
46
+ raise SecurityError(
47
+ f"token file {target} already exists; use --force to replace it "
48
+ "(clients using the old token will be rejected)"
49
+ )
50
+ try:
51
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
52
+ # Write to a private temp file and rename it into place, so the token is
53
+ # never readable by others and an existing file is replaced atomically.
54
+ fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
55
+ try:
56
+ with os.fdopen(fd, "w") as f:
57
+ f.write(secrets.token_hex(TOKEN_BYTES) + "\n")
58
+ os.chmod(tmp, 0o600)
59
+ os.replace(tmp, target)
60
+ except BaseException:
61
+ os.unlink(tmp)
62
+ raise
63
+ except OSError as exc:
64
+ raise SecurityError(f"cannot write token file {target}: {exc.strerror or exc}") from exc
65
+ return target
66
+
67
+
68
+ def read_secret_file(path: str | os.PathLike, what: str = "token") -> str:
69
+ p = Path(path).expanduser()
70
+ try:
71
+ st = p.stat()
72
+ value = p.read_text().strip()
73
+ except OSError as exc:
74
+ raise SecurityError(f"cannot read {what} file {p}: {exc.strerror or exc}") from exc
75
+ if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
76
+ log.warning("%s file %s is accessible by group/others; consider chmod 600", what, p)
77
+ if not value:
78
+ raise SecurityError(f"{what} file {p} is empty")
79
+ return value
80
+
81
+
82
+ def read_bytes(path: str | os.PathLike, what: str) -> bytes:
83
+ p = Path(path).expanduser()
84
+ try:
85
+ return p.read_bytes()
86
+ except OSError as exc:
87
+ raise SecurityError(f"cannot read {what} {p}: {exc.strerror or exc}") from exc
88
+
89
+
90
+ def is_loopback_host(host: str) -> bool:
91
+ if host in ("localhost", "localhost.localdomain"):
92
+ return True
93
+ try:
94
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
95
+ except ValueError:
96
+ return False
97
+
98
+
99
+ # ---------------------------------------------------------------- server side
100
+
101
+
102
+ def server_credentials(cert: str, key: str, client_ca: str | None) -> grpc.ServerCredentials:
103
+ """TLS credentials; with ``client_ca`` client certificates are required (mTLS)."""
104
+ return grpc.ssl_server_credentials(
105
+ [(read_bytes(key, "TLS key"), read_bytes(cert, "TLS certificate"))],
106
+ root_certificates=read_bytes(client_ca, "client CA") if client_ca else None,
107
+ require_client_auth=bool(client_ca),
108
+ )
109
+
110
+
111
+ class TokenAuthInterceptor(grpc.aio.ServerInterceptor):
112
+ """Rejects calls that do not carry the expected bearer token."""
113
+
114
+ def __init__(self, token: str):
115
+ self._expected = (BEARER_PREFIX + token).encode()
116
+
117
+ def _authorized(self, metadata) -> bool:
118
+ for key, value in metadata or ():
119
+ if key == AUTH_METADATA_KEY:
120
+ got = value.encode() if isinstance(value, str) else value
121
+ return hmac.compare_digest(got, self._expected)
122
+ return False
123
+
124
+ async def intercept_service(self, continuation, handler_call_details):
125
+ handler = await continuation(handler_call_details)
126
+ if handler is None or self._authorized(handler_call_details.invocation_metadata):
127
+ return handler
128
+ audit.warning("auth_failed method=%s", handler_call_details.method)
129
+ return _deny_handler(handler)
130
+
131
+
132
+ def _deny_handler(handler: grpc.RpcMethodHandler) -> grpc.RpcMethodHandler:
133
+ async def deny(request_or_iterator, context):
134
+ await context.abort(grpc.StatusCode.UNAUTHENTICATED, "missing or invalid authentication token")
135
+
136
+ kwargs = dict(
137
+ request_deserializer=handler.request_deserializer,
138
+ response_serializer=handler.response_serializer,
139
+ )
140
+ if handler.unary_unary:
141
+ return grpc.unary_unary_rpc_method_handler(deny, **kwargs)
142
+ if handler.unary_stream:
143
+ return grpc.unary_stream_rpc_method_handler(deny, **kwargs)
144
+ if handler.stream_unary:
145
+ return grpc.stream_unary_rpc_method_handler(deny, **kwargs)
146
+ return grpc.stream_stream_rpc_method_handler(deny, **kwargs)
147
+
148
+
149
+ def peer_identity(context: grpc.aio.ServicerContext) -> str:
150
+ """Best-effort description of the caller for audit logs."""
151
+ peer = context.peer() or "unknown"
152
+ try:
153
+ auth = context.auth_context() or {}
154
+ except Exception: # pragma: no cover - defensive
155
+ auth = {}
156
+ cn = auth.get("x509_common_name")
157
+ if cn:
158
+ return f"{peer} cn={cn[0].decode(errors='replace')}"
159
+ return peer
160
+
161
+
162
+ # ---------------------------------------------------------------- client side
163
+
164
+
165
+ def channel_credentials(
166
+ ca_cert: str | None, client_cert: str | None, client_key: str | None
167
+ ) -> grpc.ChannelCredentials:
168
+ if bool(client_cert) != bool(client_key):
169
+ raise SecurityError("client_cert and client_key must be given together")
170
+ return grpc.ssl_channel_credentials(
171
+ root_certificates=read_bytes(ca_cert, "CA certificate") if ca_cert else None,
172
+ private_key=read_bytes(client_key, "client key") if client_key else None,
173
+ certificate_chain=read_bytes(client_cert, "client certificate") if client_cert else None,
174
+ )
175
+
176
+
177
+ def auth_metadata(token: str | None) -> tuple[tuple[str, str], ...]:
178
+ return ((AUTH_METADATA_KEY, BEARER_PREFIX + token),) if token else ()