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 +45 -0
- cofferdam/cli.py +138 -0
- cofferdam/config.py +66 -0
- cofferdam/credentials.py +51 -0
- cofferdam/decisions.py +174 -0
- cofferdam/errors.py +67 -0
- cofferdam/http.py +139 -0
- cofferdam/mail.py +177 -0
- cofferdam/models.py +145 -0
- cofferdam/py.typed +0 -0
- cofferdam/validators.py +100 -0
- cofferdam-0.0.0.dist-info/METADATA +214 -0
- cofferdam-0.0.0.dist-info/RECORD +16 -0
- cofferdam-0.0.0.dist-info/WHEEL +4 -0
- cofferdam-0.0.0.dist-info/entry_points.txt +2 -0
- cofferdam-0.0.0.dist-info/licenses/LICENSE +201 -0
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)
|
cofferdam/credentials.py
ADDED
|
@@ -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")
|