cofferdam 0.0.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.
cofferdam/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """cofferdam — block outbound side effects from non-production environments.
2
+
3
+ A plain Python library that makes a local TOML policy file — not the restored
4
+ database — the authority over which outbound side effects (vendor API calls,
5
+ email, payments, webhooks, report delivery, file transfer) are permitted in the
6
+ current environment.
7
+
8
+ Design rule: *Restored data owns business intent. Local environment config owns
9
+ execution authority.* The engine fails closed. See ``docs/`` for the full
10
+ requirements specification and ``docs/adr/`` for architecture decisions.
11
+
12
+ Frappe/ERPNext integration is handled by the companion app ``cofferdam-app``
13
+ (ADR-0012). This package has no Frappe dependency at any layer.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from cofferdam.config import load_policy, loads_policy
19
+ from cofferdam.decisions import Decision
20
+ from cofferdam.errors import (
21
+ CofferdamError,
22
+ CredentialError,
23
+ PolicyDeniedError,
24
+ PolicyFileNotFoundError,
25
+ PolicyValidationError,
26
+ SecretResolutionError,
27
+ )
28
+ from cofferdam.models import Environment, Policy, SideEffectKind
29
+
30
+ __all__ = [
31
+ "CofferdamError",
32
+ "CredentialError",
33
+ "Decision",
34
+ "Environment",
35
+ "Policy",
36
+ "PolicyDeniedError",
37
+ "PolicyFileNotFoundError",
38
+ "PolicyValidationError",
39
+ "SecretResolutionError",
40
+ "SideEffectKind",
41
+ "load_policy",
42
+ "loads_policy",
43
+ ]
44
+
45
+ __version__ = "0.1.0"
cofferdam/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ """Command-line interface for cofferdam (BR-CLI-*).
2
+
3
+ Provides three commands over a local policy file:
4
+
5
+ cofferdam validate PATH
6
+ cofferdam inspect PATH
7
+ cofferdam decide PATH --integration ... --kind ... --operation ... \\
8
+ --method ... --url ... --credential ...
9
+
10
+ ``validate`` exits non-zero on an invalid policy. ``inspect`` prints a redacted
11
+ summary. ``decide`` prints allow/deny and a reason code. No command ever prints a
12
+ secret value (BR-CLI-004). Uses the stdlib ``argparse`` to keep the core
13
+ dependency-free.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from collections.abc import Sequence
21
+
22
+ from cofferdam.config import load_policy
23
+ from cofferdam.decisions import decide
24
+ from cofferdam.errors import CofferdamError
25
+ from cofferdam.http import host_from_url
26
+
27
+ EXIT_OK = 0
28
+ EXIT_DENIED = 1
29
+ EXIT_INVALID = 2
30
+ EXIT_USAGE = 3
31
+
32
+
33
+ def _cmd_validate(args: argparse.Namespace) -> int:
34
+ try:
35
+ load_policy(args.path, strict=args.strict)
36
+ except CofferdamError as exc:
37
+ print(f"INVALID: {exc}", file=sys.stderr)
38
+ for problem in getattr(exc, "problems", []) or []:
39
+ print(f" - {problem}", file=sys.stderr)
40
+ return EXIT_INVALID
41
+ print(f"OK: {args.path} is a valid policy")
42
+ return EXIT_OK
43
+
44
+
45
+ def _cmd_inspect(args: argparse.Namespace) -> int:
46
+ try:
47
+ policy = load_policy(args.path)
48
+ except CofferdamError as exc:
49
+ print(f"INVALID: {exc}", file=sys.stderr)
50
+ return EXIT_INVALID
51
+
52
+ print(f"environment : {policy.environment.value}")
53
+ print(f"default_decision : {policy.default_decision.value}")
54
+ if policy.mail is not None:
55
+ print(f"mail.mode : {policy.mail.mode}")
56
+ print(f"integrations : {len(policy.integrations)}")
57
+ for name, integ in sorted(policy.integrations.items()):
58
+ state = "enabled" if integ.enabled else "disabled"
59
+ hosts = ", ".join(integ.allowed_hosts) or "(none)"
60
+ print(f" - {name} [{integ.kind.value}, {state}] hosts: {hosts}")
61
+ print(f"credentials : {len(policy.credentials)}")
62
+ for name, cred in sorted(policy.credentials.items()):
63
+ # Redacted: show the source *name*, never a value (BR-CLI-004).
64
+ source = f"env:{cred.secret_env}" if cred.secret_env else "raw:<redacted>"
65
+ print(f" - {name} [profile={cred.profile}, source={source}]")
66
+ return EXIT_OK
67
+
68
+
69
+ def _cmd_decide(args: argparse.Namespace) -> int:
70
+ try:
71
+ policy = load_policy(args.path)
72
+ except CofferdamError as exc:
73
+ print(f"INVALID: {exc}", file=sys.stderr)
74
+ return EXIT_INVALID
75
+
76
+ host = host_from_url(args.url) if args.url else args.host
77
+ decision = decide(
78
+ policy,
79
+ integration=args.integration,
80
+ kind=args.kind,
81
+ operation=args.operation,
82
+ method=args.method,
83
+ host=host,
84
+ credential=args.credential,
85
+ )
86
+ verdict = "ALLOW" if decision.allowed else "DENY"
87
+ print(f"{verdict} reason={decision.reason_code}")
88
+ if decision.detail:
89
+ print(f" detail: {decision.detail}")
90
+ return EXIT_OK if decision.allowed else EXIT_DENIED
91
+
92
+
93
+ def build_parser() -> argparse.ArgumentParser:
94
+ parser = argparse.ArgumentParser(
95
+ prog="cofferdam",
96
+ description="Guard non-Production Frappe/ERPNext outbound side effects.",
97
+ )
98
+ sub = parser.add_subparsers(dest="command", required=True)
99
+
100
+ p_validate = sub.add_parser("validate", help="validate a policy file")
101
+ p_validate.add_argument("path")
102
+ p_validate.add_argument(
103
+ "--strict",
104
+ action="store_true",
105
+ help="also require secret_env vars to be set and reject raw secrets",
106
+ )
107
+ p_validate.set_defaults(func=_cmd_validate)
108
+
109
+ p_inspect = sub.add_parser("inspect", help="print a redacted policy summary")
110
+ p_inspect.add_argument("path")
111
+ p_inspect.set_defaults(func=_cmd_inspect)
112
+
113
+ p_decide = sub.add_parser("decide", help="simulate a policy decision")
114
+ p_decide.add_argument("path")
115
+ p_decide.add_argument("--integration", required=True)
116
+ p_decide.add_argument("--kind")
117
+ p_decide.add_argument("--operation")
118
+ p_decide.add_argument("--method")
119
+ p_decide.add_argument("--url", help="target URL; hostname is parsed structurally")
120
+ p_decide.add_argument("--host", help="target hostname (alternative to --url)")
121
+ p_decide.add_argument("--credential")
122
+ p_decide.set_defaults(func=_cmd_decide)
123
+
124
+ return parser
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None) -> int:
128
+ parser = build_parser()
129
+ args = parser.parse_args(argv)
130
+ try:
131
+ return int(args.func(args))
132
+ except CofferdamError as exc:
133
+ print(f"ERROR: {exc}", file=sys.stderr)
134
+ return EXIT_INVALID
135
+
136
+
137
+ if __name__ == "__main__": # pragma: no cover
138
+ raise SystemExit(main())
cofferdam/config.py ADDED
@@ -0,0 +1,66 @@
1
+ """Load and parse TOML policy files (BR-CONFIG-*).
2
+
3
+ Uses the standard-library ``tomllib`` on Python 3.11+ and the ``tomli`` backport
4
+ on 3.10. Parsing produces a validated :class:`~cofferdam.models.Policy`; semantic
5
+ checks that span multiple sections are applied by :mod:`cofferdam.validators`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else: # pragma: no cover - exercised on 3.10 only
17
+ import tomli as tomllib
18
+
19
+ from pydantic import ValidationError
20
+
21
+ from cofferdam.errors import PolicyFileNotFoundError, PolicyValidationError
22
+ from cofferdam.models import Policy
23
+
24
+
25
+ def _build_policy(data: dict[str, Any], *, source: str, strict: bool) -> Policy:
26
+ try:
27
+ policy = Policy.model_validate(data)
28
+ except ValidationError as exc:
29
+ problems = [
30
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
31
+ ]
32
+ raise PolicyValidationError(
33
+ f"invalid policy schema in {source}", problems=problems
34
+ ) from exc
35
+
36
+ from cofferdam.validators import validate_policy
37
+
38
+ validate_policy(policy, strict=strict, source=source)
39
+ return policy
40
+
41
+
42
+ def loads_policy(text: str, *, source: str = "<string>", strict: bool = False) -> Policy:
43
+ """Parse and validate a policy from a TOML string."""
44
+ try:
45
+ data = tomllib.loads(text)
46
+ except tomllib.TOMLDecodeError as exc:
47
+ raise PolicyValidationError(f"malformed TOML in {source}: {exc}") from exc
48
+ return _build_policy(data, source=source, strict=strict)
49
+
50
+
51
+ def load_policy(path: str | Path, *, strict: bool = False) -> Policy:
52
+ """Load, parse, and validate a policy from a filesystem path.
53
+
54
+ :param strict: when True, apply stricter semantic checks (for example,
55
+ verifying that every ``secret_env`` variable is set). Intended for the
56
+ ``cofferdam validate`` CLI and CI, not the hot path.
57
+ :raises PolicyFileNotFoundError: the file does not exist (fail-closed;
58
+ BR-DECISION-001).
59
+ :raises PolicyValidationError: the file is malformed or invalid.
60
+ """
61
+ p = Path(path)
62
+ try:
63
+ text = p.read_text(encoding="utf-8")
64
+ except FileNotFoundError as exc:
65
+ raise PolicyFileNotFoundError(f"policy file not found: {p}") from exc
66
+ return loads_policy(text, source=str(p), strict=strict)
@@ -0,0 +1,51 @@
1
+ """Credential reference resolution (BR-SECRET-*).
2
+
3
+ A credential names *where* a secret comes from — it is not the secret. In v1 the
4
+ only supported source is an environment variable (``secret_env``). Raw inline
5
+ secrets (``secret_value``) are rejected unless the caller explicitly opts in
6
+ (ADR-0007). No function here logs, prints, or embeds a secret value in an
7
+ exception message (BR-LOG-002).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ from cofferdam.errors import CredentialError, SecretResolutionError
15
+ from cofferdam.models import Policy
16
+
17
+
18
+ def resolve_secret(policy: Policy, name: str, *, allow_raw: bool = False) -> str:
19
+ """Resolve the secret material for credential ``name``.
20
+
21
+ :param name: key into ``policy.credentials``.
22
+ :param allow_raw: when True, a raw ``secret_value`` may be returned. Defaults
23
+ to False so that inline secrets are refused (ADR-0007, BR-SECRET-003).
24
+ :raises CredentialError: the credential is undefined, or defines no usable
25
+ source, or uses a raw secret without opt-in.
26
+ :raises SecretResolutionError: ``secret_env`` names an unset variable.
27
+ """
28
+ cred = policy.credentials.get(name)
29
+ if cred is None:
30
+ raise CredentialError(f"credential {name!r} is not defined in the policy")
31
+
32
+ if cred.secret_env:
33
+ try:
34
+ return os.environ[cred.secret_env]
35
+ except KeyError:
36
+ raise SecretResolutionError(
37
+ f"credential {name!r} requires environment variable "
38
+ f"{cred.secret_env!r}, which is not set"
39
+ ) from None
40
+
41
+ if cred.secret_value is not None:
42
+ if not allow_raw:
43
+ raise CredentialError(
44
+ f"credential {name!r} uses a raw inline secret_value, which is "
45
+ f"rejected by default; pass allow_raw=True to permit it"
46
+ )
47
+ return cred.secret_value
48
+
49
+ raise CredentialError(
50
+ f"credential {name!r} defines no secret source (expected secret_env)"
51
+ )
cofferdam/decisions.py ADDED
@@ -0,0 +1,174 @@
1
+ """The allow/deny decision engine (BR-DECISION-*).
2
+
3
+ The engine is **fail-closed** (ADR-0005): a side effect is allowed only when the
4
+ policy *explicitly* permits every dimension the caller supplies. Any missing,
5
+ disabled, mismatched, or empty-allowlist condition denies. Each decision carries
6
+ a stable ``reason_code`` and a redacted, log-safe context (BR-LOG-001) that never
7
+ includes a secret value (BR-LOG-002).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from cofferdam.errors import PolicyDeniedError
15
+ from cofferdam.models import Policy, SideEffectKind
16
+
17
+
18
+ def host_matches(host: str, allowed_hosts: list[str]) -> bool:
19
+ """Return True iff ``host`` exactly matches an allowed hostname.
20
+
21
+ Comparison is case-insensitive and matches whole hostnames only, never
22
+ substrings, so ``sandbox.vendor.com`` never authorizes
23
+ ``sandbox.vendor.com.evil.example`` (BR-HOST-001). Extraction of a hostname
24
+ from a full URL is the caller's responsibility (see :mod:`cofferdam.http`).
25
+ """
26
+ needle = host.strip().rstrip(".").lower()
27
+ if not needle:
28
+ return False
29
+ return any(needle == allowed.strip().rstrip(".").lower() for allowed in allowed_hosts)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Decision:
34
+ """The structured outcome of a policy evaluation.
35
+
36
+ ``allowed`` is the verdict; ``reason_code`` is a stable machine-readable
37
+ token (e.g. ``"host_not_allowed"``). The remaining fields are log-safe
38
+ context. ``credential`` is the credential *reference name*, never its value.
39
+ """
40
+
41
+ allowed: bool
42
+ reason_code: str
43
+ environment: str | None = None
44
+ integration: str | None = None
45
+ kind: str | None = None
46
+ operation: str | None = None
47
+ method: str | None = None
48
+ host: str | None = None
49
+ credential: str | None = None
50
+ detail: str = ""
51
+
52
+ def as_log_dict(self) -> dict[str, object]:
53
+ """Return a redacted mapping suitable for structured logging (BR-LOG-001)."""
54
+ return {
55
+ "allowed": self.allowed,
56
+ "reason_code": self.reason_code,
57
+ "environment": self.environment,
58
+ "integration": self.integration,
59
+ "kind": self.kind,
60
+ "operation": self.operation,
61
+ "method": self.method,
62
+ "host": self.host,
63
+ "credential": self.credential, # reference name only, never the secret
64
+ "detail": self.detail,
65
+ }
66
+
67
+ def message(self) -> str:
68
+ verb = "allowed" if self.allowed else "denied"
69
+ return (
70
+ f"outbound side effect {verb} "
71
+ f"[env={self.environment} integration={self.integration} "
72
+ f"kind={self.kind} operation={self.operation}] reason={self.reason_code}"
73
+ + (f": {self.detail}" if self.detail else "")
74
+ )
75
+
76
+ def to_exception(self) -> PolicyDeniedError:
77
+ """Build (but do not raise) a :class:`PolicyDeniedError` from this decision."""
78
+ return PolicyDeniedError(self)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class _Ctx:
83
+ """Common evaluation context threaded into each Decision."""
84
+
85
+ environment: str
86
+ integration: str | None = None
87
+ kind: str | None = None
88
+ operation: str | None = None
89
+ method: str | None = None
90
+ host: str | None = None
91
+ credential: str | None = None
92
+
93
+ def deny(self, reason_code: str, detail: str = "") -> Decision:
94
+ return Decision(allowed=False, reason_code=reason_code, detail=detail, **vars(self))
95
+
96
+ def allow(self, reason_code: str = "explicitly_allowed", detail: str = "") -> Decision:
97
+ return Decision(allowed=True, reason_code=reason_code, detail=detail, **vars(self))
98
+
99
+
100
+ def decide(
101
+ policy: Policy,
102
+ *,
103
+ integration: str,
104
+ kind: str | None = None,
105
+ operation: str | None = None,
106
+ method: str | None = None,
107
+ host: str | None = None,
108
+ credential: str | None = None,
109
+ ) -> Decision:
110
+ """Evaluate whether a single outbound side effect is permitted (BR-DECISION-001..010).
111
+
112
+ Only the dimensions the caller supplies are enforced, and each supplied
113
+ dimension must appear in the integration's explicit allowlist. An empty
114
+ allowlist denies (fail-closed). See ``docs/04-decision-engine.md``.
115
+ """
116
+ ctx = _Ctx(
117
+ environment=policy.environment.value,
118
+ integration=integration,
119
+ kind=kind,
120
+ operation=operation,
121
+ method=method,
122
+ host=host,
123
+ credential=credential,
124
+ )
125
+
126
+ integ = policy.integrations.get(integration)
127
+ if integ is None:
128
+ return ctx.deny("unknown_integration")
129
+ if not integ.enabled:
130
+ return ctx.deny("integration_disabled")
131
+
132
+ if kind is not None and integ.kind.value != kind:
133
+ return ctx.deny("kind_mismatch", f"policy declares kind={integ.kind.value}")
134
+
135
+ # Credential reference must exist and, if the integration pins one, must match.
136
+ if credential is not None:
137
+ if credential not in policy.credentials:
138
+ return ctx.deny("missing_credential")
139
+ if integ.credential is not None and integ.credential != credential:
140
+ return ctx.deny("credential_mismatch", f"integration requires {integ.credential!r}")
141
+
142
+ # Operation must be explicitly allowed when supplied (empty list denies).
143
+ if operation is not None and operation not in integ.allowed_operations:
144
+ return ctx.deny("operation_not_allowed")
145
+
146
+ # Payment-specific gates.
147
+ if integ.kind is SideEffectKind.PAYMENT:
148
+ if operation == "authorize" and not integ.allow_authorize:
149
+ return ctx.deny("payment_authorize_not_allowed")
150
+ if operation == "capture" and not integ.allow_capture:
151
+ return ctx.deny("payment_capture_not_allowed")
152
+
153
+ # HTTP method must be explicitly allowed when supplied (empty list denies).
154
+ if method is not None:
155
+ allowed_methods = {m.upper() for m in integ.allowed_methods}
156
+ if method.upper() not in allowed_methods:
157
+ return ctx.deny("method_not_allowed")
158
+
159
+ # Host must be explicitly allowed when supplied (empty list denies).
160
+ if host is not None and not host_matches(host, integ.allowed_hosts):
161
+ return ctx.deny("host_not_allowed")
162
+
163
+ return ctx.allow()
164
+
165
+
166
+ def assert_allowed(policy: Policy, **kwargs: object) -> Decision:
167
+ """Evaluate a side effect and raise :class:`PolicyDeniedError` if denied.
168
+
169
+ Returns the allowing :class:`Decision` so callers may log it.
170
+ """
171
+ decision = decide(policy, **kwargs) # type: ignore[arg-type]
172
+ if not decision.allowed:
173
+ raise decision.to_exception()
174
+ return decision
cofferdam/errors.py ADDED
@@ -0,0 +1,67 @@
1
+ """Explicit exception types for cofferdam.
2
+
3
+ Implements the requirement that unsafe calls *fail closed with clear errors*
4
+ (docs/01-overview.md, docs/04-decision-engine.md). Every exception carries
5
+ enough structured context for server-side logging while guaranteeing that no
6
+ secret value is ever placed in an exception message (BR-LOG-002).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from cofferdam.decisions import Decision
15
+
16
+
17
+ class CofferdamError(Exception):
18
+ """Base class for every error raised by cofferdam."""
19
+
20
+
21
+ class PolicyFileNotFoundError(CofferdamError):
22
+ """A policy file was expected on the local filesystem but not found.
23
+
24
+ Per BR-DECISION-001 this is a fail-closed condition: a missing policy file
25
+ denies all side effects unless the caller explicitly opted into a fallback.
26
+ """
27
+
28
+
29
+ class PolicyValidationError(CofferdamError):
30
+ """A policy file is syntactically or semantically invalid.
31
+
32
+ Aggregates one or more human-readable problems. Distinguishes syntax errors
33
+ (malformed TOML) from semantic policy errors (BR-VALIDATE-011).
34
+ """
35
+
36
+ def __init__(self, message: str, *, problems: list[str] | None = None) -> None:
37
+ super().__init__(message)
38
+ self.problems: list[str] = problems or []
39
+
40
+
41
+ class PolicyDeniedError(CofferdamError):
42
+ """Raised by ``assert_allowed`` when a side effect is not permitted.
43
+
44
+ Carries the originating :class:`~cofferdam.decisions.Decision` so callers and
45
+ logs can see the environment, integration, operation, and reason code — but
46
+ never a secret value.
47
+ """
48
+
49
+ def __init__(self, decision: Decision) -> None:
50
+ self.decision = decision
51
+ super().__init__(decision.message())
52
+
53
+
54
+ class CredentialError(CofferdamError):
55
+ """A credential reference is missing, malformed, or mismatched.
56
+
57
+ Examples: an integration names a credential that the policy does not define,
58
+ or the credential's ``profile`` does not match what the integration requires.
59
+ """
60
+
61
+
62
+ class SecretResolutionError(CredentialError):
63
+ """A credential is defined but its secret material could not be resolved.
64
+
65
+ Example: ``secret_env`` names an environment variable that is unset. The
66
+ missing variable name may appear in the message; the secret value never can.
67
+ """
cofferdam/http.py ADDED
@@ -0,0 +1,139 @@
1
+ """Policy-checked HTTP helper (BR-HTTP-*).
2
+
3
+ This module wraps ``httpx`` and is only available when the optional dependency is
4
+ installed (``pip install "cofferdam[http]"``, ADR-0008). The host parsed from the
5
+ request URL is evaluated by the policy engine before any bytes or authorization
6
+ headers are sent (BR-HOST-002, BR-HOST-004).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any, cast
12
+ from urllib.parse import urlsplit
13
+
14
+ from cofferdam.decisions import decide
15
+ from cofferdam.errors import CofferdamError
16
+
17
+ if TYPE_CHECKING:
18
+ import httpx
19
+
20
+ from cofferdam.models import Policy
21
+
22
+ _MAX_REDIRECTS = 10
23
+
24
+
25
+ def _require_httpx() -> Any:
26
+ try:
27
+ import httpx
28
+ except ModuleNotFoundError as exc: # pragma: no cover - trivial guard
29
+ raise CofferdamError(
30
+ "cofferdam.http requires the optional 'http' extra: "
31
+ 'pip install "cofferdam[http]"'
32
+ ) from exc
33
+ return httpx
34
+
35
+
36
+ def host_from_url(url: str) -> str:
37
+ """Extract the lowercase hostname from ``url`` using a structured parser.
38
+
39
+ Uses :func:`urllib.parse.urlsplit` so policy evaluation compares the real
40
+ hostname, never a substring of the raw URL (BR-HOST-001, BR-HOST-002).
41
+ """
42
+ host = urlsplit(url).hostname
43
+ if not host:
44
+ raise CofferdamError(f"could not parse a hostname from URL: {url!r}")
45
+ return host.lower()
46
+
47
+
48
+ def request(
49
+ *,
50
+ policy: Policy,
51
+ integration: str,
52
+ operation: str,
53
+ method: str,
54
+ url: str,
55
+ credential: str | None = None,
56
+ follow_redirects: bool = False,
57
+ **kwargs: Any,
58
+ ) -> httpx.Response:
59
+ """Issue a policy-checked HTTP request.
60
+
61
+ Evaluates the policy — including the hostname from ``url`` — before any bytes
62
+ leave (BR-HOST-002, BR-HOST-004). When ``credential`` is supplied its resolved
63
+ secret is injected as ``Authorization: Bearer``. Redirects are not followed by
64
+ default (BR-HTTP-003); when enabled every redirect target is re-validated
65
+ before credentials are forwarded.
66
+ """
67
+ httpx_mod = _require_httpx()
68
+
69
+ host = host_from_url(url)
70
+ decision = decide(
71
+ policy,
72
+ integration=integration,
73
+ kind=None,
74
+ operation=operation,
75
+ method=method,
76
+ host=host,
77
+ credential=credential,
78
+ )
79
+ if not decision.allowed:
80
+ raise decision.to_exception()
81
+
82
+ # Resolve and inject credential as Authorization: Bearer (BR-SECRET-001, BR-HOST-004)
83
+ headers: dict[str, str] = {}
84
+ if credential is not None:
85
+ from cofferdam.credentials import resolve_secret
86
+
87
+ headers["Authorization"] = f"Bearer {resolve_secret(policy, credential)}"
88
+
89
+ # Merge caller-supplied headers; our Authorization takes precedence
90
+ if "headers" in kwargs:
91
+ merged: dict[str, str] = dict(kwargs.pop("headers"))
92
+ merged.update(headers)
93
+ headers = merged
94
+
95
+ if not follow_redirects:
96
+ return cast(
97
+ "httpx.Response",
98
+ httpx_mod.request(method, url, headers=headers, follow_redirects=False, **kwargs),
99
+ )
100
+
101
+ # Manual redirect loop: re-validate every hop before forwarding credentials (BR-HTTP-003)
102
+ body_keys = {"json", "data", "content", "files"}
103
+ body_kw: dict[str, Any] = {k: v for k, v in kwargs.items() if k in body_keys}
104
+ other_kw: dict[str, Any] = {k: v for k, v in kwargs.items() if k not in body_keys}
105
+ current_url = url
106
+ current_method = method
107
+
108
+ for _ in range(_MAX_REDIRECTS + 1):
109
+ response = httpx_mod.request(
110
+ current_method,
111
+ current_url,
112
+ headers=headers,
113
+ follow_redirects=False,
114
+ **body_kw,
115
+ **other_kw,
116
+ )
117
+ if not response.is_redirect:
118
+ return cast("httpx.Response", response)
119
+
120
+ redirect_url: str = cast(str, response.headers.get("location", ""))
121
+ redirect_host = host_from_url(redirect_url)
122
+ rd = decide(
123
+ policy,
124
+ integration=integration,
125
+ kind=None,
126
+ operation=operation,
127
+ method=current_method,
128
+ host=redirect_host,
129
+ credential=credential,
130
+ )
131
+ if not rd.allowed:
132
+ raise rd.to_exception()
133
+
134
+ current_url = redirect_url
135
+ if response.status_code in (301, 302, 303):
136
+ current_method = "GET"
137
+ body_kw = {}
138
+
139
+ raise CofferdamError(f"exceeded {_MAX_REDIRECTS} redirects")