authweave-otel 7.1.2__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Python bytecode and native build products
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.so
8
+
9
+ # Local environments and secrets
10
+ /.venv/
11
+ /.uv-cache/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+ # Packaging output
17
+ /build/
18
+ /dist/
19
+ *.egg-info/
20
+
21
+ # Test, coverage, and tool caches
22
+ .coverage
23
+ .coverage.*
24
+ /coverage.xml
25
+ /htmlcov/
26
+ /.pytest_cache/
27
+ /.ruff_cache/
28
+ /.cache/
29
+
30
+ # Generated documentation and code intelligence
31
+ /site/
32
+ /docs/_include/
33
+ /.codegraph/
34
+
35
+ # Docker reference ephemeral key material
36
+ /docker/reference/http-signatures/_runtime/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Shepilov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: authweave-otel
3
+ Version: 7.1.2
4
+ Summary: OpenTelemetry API-only security observability facade for the AuthWeave stack
5
+ Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
6
+ Project-URL: documentation, https://zylvext.github.io/litestar-auth/
7
+ Project-URL: source, https://github.com/ZYLVEXT/litestar-auth
8
+ Project-URL: tracker, https://github.com/ZYLVEXT/litestar-auth/issues
9
+ Author-email: Vladislav Shepilov <shepilov.v@protonmail.com>
10
+ Maintainer-email: Vladislav Shepilov <shepilov.v@protonmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: authentication,metrics,observability,opentelemetry,security,tracing
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: <3.15.0,>=3.12.0
24
+ Requires-Dist: authweave-core==7.1.2
25
+ Requires-Dist: opentelemetry-api<2.0,>=1.44.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # authweave-otel
29
+
30
+ OpenTelemetry API-only security observability facade for the AuthWeave
31
+ authentication stack.
32
+
33
+ `authweave-otel` depends only on `opentelemetry-api` and `authweave-core`. It
34
+ never installs an SDK, exporter, sampler, propagator, or `service.name`. Without
35
+ a configured SDK every span and metric is a no-op, so instrumentation is safe and
36
+ free by default.
37
+
38
+ The application owns the `TracerProvider`/`MeterProvider`, sampling, export, and
39
+ retention. Construct `AuthWeaveTelemetry` with global or explicit providers and
40
+ pass it into your AuthWeave adapter configuration. `LitestarAuth` creates only
41
+ `INTERNAL` child security spans inside the server span produced by normal ASGI
42
+ instrumentation; it never creates a second HTTP span:
43
+
44
+ ```python
45
+ from authweave_otel import AuthWeaveTelemetry
46
+ from litestar_auth import LitestarAuthConfig
47
+
48
+ telemetry = AuthWeaveTelemetry()
49
+ config = LitestarAuthConfig(
50
+ # normal required configuration omitted
51
+ observer=telemetry,
52
+ )
53
+ ```
54
+
55
+ The same observer can be passed to the HTTP-signature/webhook verifier and
56
+ webhook sender constructors. `verify(..., links=(TraceCorrelation(...),))` and
57
+ `send(..., links=...)` attach retry/async causal links without accepting trace
58
+ context as identity. Direct `operation_span()` and metric methods remain
59
+ available for application-owned operations.
60
+
61
+ ## Privacy and abuse resistance
62
+
63
+ - Attribute keys are allowlisted (`authweave.*` catalog only).
64
+ - Values are truncated to 64 characters and redacted when they match secret
65
+ canaries (tokens, cookies, PEM headers, payment fragments).
66
+ - Unknown reason codes collapse to `_OTHER` so hostile input cannot grow label
67
+ cardinality.
68
+ - Baggage extraction/forwarding defaults to off
69
+ (`DEFAULT_TRACE_CONTEXT_POLICY`); remote parents are never trusted identity.
70
+ - Telemetry never participates in authentication, authorization, replay, or
71
+ idempotency decisions. Collector/exporter outage must not change auth results.
72
+ - Workload `SecurityEvent` values receive the active `trace_id`/`span_id` for
73
+ lookup only. The mandatory event callback remains the durable audit channel.
74
+
75
+ ## Catalog and reference stack
76
+
77
+ Span names, metric names, units, and attribute keys are a versioned catalog. See
78
+ `authweave_otel.catalog`; renames and cardinality growth are breaking changes.
79
+
80
+ A non-production Collector/Prometheus/Tempo/Grafana stack lives at
81
+ `docker/reference/observability/`. Run `sh docker/reference/observability/verify.sh`
82
+ for the synthetic smoke test and golden Prometheus name checks after the pinned
83
+ OTel→Prometheus translation.
@@ -0,0 +1,56 @@
1
+ # authweave-otel
2
+
3
+ OpenTelemetry API-only security observability facade for the AuthWeave
4
+ authentication stack.
5
+
6
+ `authweave-otel` depends only on `opentelemetry-api` and `authweave-core`. It
7
+ never installs an SDK, exporter, sampler, propagator, or `service.name`. Without
8
+ a configured SDK every span and metric is a no-op, so instrumentation is safe and
9
+ free by default.
10
+
11
+ The application owns the `TracerProvider`/`MeterProvider`, sampling, export, and
12
+ retention. Construct `AuthWeaveTelemetry` with global or explicit providers and
13
+ pass it into your AuthWeave adapter configuration. `LitestarAuth` creates only
14
+ `INTERNAL` child security spans inside the server span produced by normal ASGI
15
+ instrumentation; it never creates a second HTTP span:
16
+
17
+ ```python
18
+ from authweave_otel import AuthWeaveTelemetry
19
+ from litestar_auth import LitestarAuthConfig
20
+
21
+ telemetry = AuthWeaveTelemetry()
22
+ config = LitestarAuthConfig(
23
+ # normal required configuration omitted
24
+ observer=telemetry,
25
+ )
26
+ ```
27
+
28
+ The same observer can be passed to the HTTP-signature/webhook verifier and
29
+ webhook sender constructors. `verify(..., links=(TraceCorrelation(...),))` and
30
+ `send(..., links=...)` attach retry/async causal links without accepting trace
31
+ context as identity. Direct `operation_span()` and metric methods remain
32
+ available for application-owned operations.
33
+
34
+ ## Privacy and abuse resistance
35
+
36
+ - Attribute keys are allowlisted (`authweave.*` catalog only).
37
+ - Values are truncated to 64 characters and redacted when they match secret
38
+ canaries (tokens, cookies, PEM headers, payment fragments).
39
+ - Unknown reason codes collapse to `_OTHER` so hostile input cannot grow label
40
+ cardinality.
41
+ - Baggage extraction/forwarding defaults to off
42
+ (`DEFAULT_TRACE_CONTEXT_POLICY`); remote parents are never trusted identity.
43
+ - Telemetry never participates in authentication, authorization, replay, or
44
+ idempotency decisions. Collector/exporter outage must not change auth results.
45
+ - Workload `SecurityEvent` values receive the active `trace_id`/`span_id` for
46
+ lookup only. The mandatory event callback remains the durable audit channel.
47
+
48
+ ## Catalog and reference stack
49
+
50
+ Span names, metric names, units, and attribute keys are a versioned catalog. See
51
+ `authweave_otel.catalog`; renames and cardinality growth are breaking changes.
52
+
53
+ A non-production Collector/Prometheus/Tempo/Grafana stack lives at
54
+ `docker/reference/observability/`. Run `sh docker/reference/observability/verify.sh`
55
+ for the synthetic smoke test and golden Prometheus name checks after the pinned
56
+ OTel→Prometheus translation.
@@ -0,0 +1,66 @@
1
+ """OpenTelemetry API-only security observability for the AuthWeave stack."""
2
+
3
+ from authweave_otel.catalog import (
4
+ ERROR_OUTCOMES,
5
+ EXPECTED_OUTCOMES,
6
+ INSTRUMENTATION_SCOPE,
7
+ METRIC_CATALOG,
8
+ OTHER_REASON_CODE,
9
+ REASON_CODES,
10
+ SEMANTIC_CONVENTIONS_SCHEMA_URL,
11
+ AttributeKey,
12
+ Instrument,
13
+ MetricSpec,
14
+ Operation,
15
+ Outcome,
16
+ normalize_reason_code,
17
+ span_name,
18
+ )
19
+ from authweave_otel.privacy import (
20
+ ALLOWED_ATTRIBUTE_KEYS,
21
+ DEFAULT_BAGGAGE_KEYS_ALLOWLIST,
22
+ DEFAULT_TRACE_CONTEXT_POLICY,
23
+ MAX_ATTRIBUTE_COUNT,
24
+ MAX_ATTRIBUTE_VALUE_LENGTH,
25
+ REDACTED_ATTRIBUTE_VALUE,
26
+ SECRET_CANARY_FRAGMENTS,
27
+ TraceContextPolicy,
28
+ contains_secret_canary,
29
+ sanitize_attributes,
30
+ truncate_attribute_value,
31
+ )
32
+ from authweave_otel.telemetry import AuthWeaveTelemetry, KeyAgeCallback, OperationScope
33
+
34
+ __version__ = "7.1.2"
35
+
36
+ __all__ = (
37
+ "ALLOWED_ATTRIBUTE_KEYS",
38
+ "DEFAULT_BAGGAGE_KEYS_ALLOWLIST",
39
+ "DEFAULT_TRACE_CONTEXT_POLICY",
40
+ "ERROR_OUTCOMES",
41
+ "EXPECTED_OUTCOMES",
42
+ "INSTRUMENTATION_SCOPE",
43
+ "MAX_ATTRIBUTE_COUNT",
44
+ "MAX_ATTRIBUTE_VALUE_LENGTH",
45
+ "METRIC_CATALOG",
46
+ "OTHER_REASON_CODE",
47
+ "REASON_CODES",
48
+ "REDACTED_ATTRIBUTE_VALUE",
49
+ "SECRET_CANARY_FRAGMENTS",
50
+ "SEMANTIC_CONVENTIONS_SCHEMA_URL",
51
+ "AttributeKey",
52
+ "AuthWeaveTelemetry",
53
+ "Instrument",
54
+ "KeyAgeCallback",
55
+ "MetricSpec",
56
+ "Operation",
57
+ "OperationScope",
58
+ "Outcome",
59
+ "TraceContextPolicy",
60
+ "__version__",
61
+ "contains_secret_canary",
62
+ "normalize_reason_code",
63
+ "sanitize_attributes",
64
+ "span_name",
65
+ "truncate_attribute_value",
66
+ )
@@ -0,0 +1,180 @@
1
+ """Versioned, immutable telemetry catalog for AuthWeave security instrumentation.
2
+
3
+ This module is pure data and stdlib only: it never imports ``opentelemetry`` so
4
+ that the span/metric contract can be asserted by golden tests without an SDK.
5
+ Renames, unit changes, or cardinality growth are breaking changes and must be
6
+ caught by :mod:`tests.test_catalog`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+ from authweave_core import FailureCode, SecurityOperation, SecurityOutcome
15
+
16
+ #: Instrumentation scope reported for every AuthWeave span and metric.
17
+ INSTRUMENTATION_SCOPE = "authweave"
18
+
19
+ #: Pinned stable OpenTelemetry Semantic Conventions schema URL. Upgrading this is
20
+ #: a deliberate, reviewed change gated by the golden catalog test.
21
+ SEMANTIC_CONVENTIONS_SCHEMA_URL = "https://opentelemetry.io/schemas/1.30.0"
22
+
23
+ #: Reason code substituted for any value outside the bounded vocabulary so that a
24
+ #: hostile or unknown reason can never grow metric/label cardinality.
25
+ OTHER_REASON_CODE = "_OTHER"
26
+
27
+
28
+ Operation = SecurityOperation
29
+ Outcome = SecurityOutcome
30
+
31
+
32
+ #: Outcomes that represent an expected negative decision, never a span ``ERROR``.
33
+ EXPECTED_OUTCOMES = frozenset(
34
+ {Outcome.NOT_APPLICABLE, Outcome.INVALID, Outcome.REPLAY, Outcome.MISS, Outcome.STALE},
35
+ )
36
+ #: Outcomes that mark a span status ``ERROR`` because verification could not
37
+ #: complete safely or an invariant was violated.
38
+ ERROR_OUTCOMES = frozenset({Outcome.UNAVAILABLE, Outcome.INVARIANT_FAILURE, Outcome.ERROR})
39
+
40
+
41
+ class AttributeKey(StrEnum):
42
+ """Namespaced ``authweave.*`` attribute keys shared by spans and metrics."""
43
+
44
+ PROFILE = "authweave.profile"
45
+ OPERATION = "authweave.operation"
46
+ OUTCOME = "authweave.outcome"
47
+ REASON_CODE = "authweave.reason_code"
48
+ PRINCIPAL_KIND = "authweave.principal_kind"
49
+ CREDENTIAL_KIND = "authweave.credential_kind"
50
+
51
+
52
+ class Instrument(StrEnum):
53
+ """Supported synchronous instrument kinds in the catalog."""
54
+
55
+ COUNTER = "counter"
56
+ HISTOGRAM = "histogram"
57
+ OBSERVABLE_GAUGE = "observable_gauge"
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class MetricSpec:
62
+ """One versioned metric definition."""
63
+
64
+ name: str
65
+ instrument: Instrument
66
+ unit: str
67
+ description: str
68
+
69
+
70
+ #: The frozen metric catalog. Prometheus/OpenMetrics suffixes (``_total`` and
71
+ #: histogram suffixes) are added by the exporter, never encoded here.
72
+ METRIC_CATALOG: tuple[MetricSpec, ...] = (
73
+ MetricSpec(
74
+ "authweave.authentication.attempts",
75
+ Instrument.COUNTER,
76
+ "{attempt}",
77
+ "Authentication attempts by profile and outcome.",
78
+ ),
79
+ MetricSpec(
80
+ "authweave.authentication.duration",
81
+ Instrument.HISTOGRAM,
82
+ "s",
83
+ "Coordinator and provider authentication latency.",
84
+ ),
85
+ MetricSpec(
86
+ "authweave.integrity.verifications",
87
+ Instrument.COUNTER,
88
+ "{verification}",
89
+ "DPoP, HTTP signature, and webhook verification outcomes.",
90
+ ),
91
+ MetricSpec(
92
+ "authweave.integrity.duration",
93
+ Instrument.HISTOGRAM,
94
+ "s",
95
+ "Message integrity verification latency.",
96
+ ),
97
+ MetricSpec(
98
+ "authweave.replay.decisions",
99
+ Instrument.COUNTER,
100
+ "{decision}",
101
+ "Replay/nonce store decisions.",
102
+ ),
103
+ MetricSpec(
104
+ "authweave.remote.operation.duration",
105
+ Instrument.HISTOGRAM,
106
+ "s",
107
+ "JWKS, PAR, introspection, and token-exchange latency.",
108
+ ),
109
+ MetricSpec(
110
+ "authweave.remote.operation.attempts",
111
+ Instrument.COUNTER,
112
+ "{attempt}",
113
+ "Remote dependency outcomes without any URL label.",
114
+ ),
115
+ MetricSpec(
116
+ "authweave.cache.requests",
117
+ Instrument.COUNTER,
118
+ "{request}",
119
+ "Cache hit, miss, stale, and error outcomes.",
120
+ ),
121
+ MetricSpec(
122
+ "authweave.key.age",
123
+ Instrument.OBSERVABLE_GAUGE,
124
+ "s",
125
+ "Age of the current trusted key/bundle snapshot.",
126
+ ),
127
+ MetricSpec(
128
+ "authweave.webhook.delivery.attempts",
129
+ Instrument.COUNTER,
130
+ "{attempt}",
131
+ "Webhook delivery outcome and retry class.",
132
+ ),
133
+ MetricSpec(
134
+ "authweave.webhook.delivery.duration",
135
+ Instrument.HISTOGRAM,
136
+ "s",
137
+ "End-to-end webhook sender attempt latency.",
138
+ ),
139
+ )
140
+
141
+ #: Protocol-specific reason codes layered on top of the neutral core failure
142
+ #: codes for integrity, nonce, and key-distribution decisions.
143
+ _PROTOCOL_REASON_CODES = frozenset(
144
+ {
145
+ "nonce_required",
146
+ "nonce_stale",
147
+ "digest_mismatch",
148
+ "signature_invalid",
149
+ "key_unknown",
150
+ "trust_domain_rejected",
151
+ "store_unavailable",
152
+ "capacity_exceeded",
153
+ },
154
+ )
155
+
156
+ #: Bounded reason-code vocabulary sourced from the neutral core failure codes plus
157
+ #: the protocol-specific codes. Any other value collapses to
158
+ #: :data:`OTHER_REASON_CODE`.
159
+ REASON_CODES: frozenset[str] = frozenset(code.value for code in FailureCode) | _PROTOCOL_REASON_CODES
160
+
161
+
162
+ def span_name(operation: Operation) -> str:
163
+ """Return the fully qualified span name for a logical operation.
164
+
165
+ Returns:
166
+ The ``authweave.<operation>`` span name.
167
+ """
168
+ return f"{INSTRUMENTATION_SCOPE}.{operation.value}"
169
+
170
+
171
+ def normalize_reason_code(reason_code: str | None) -> str | None:
172
+ """Collapse any out-of-vocabulary reason code to a safe bounded value.
173
+
174
+ Returns:
175
+ ``None`` when no reason code is supplied, the reason code when it is in
176
+ the bounded vocabulary, otherwise :data:`OTHER_REASON_CODE`.
177
+ """
178
+ if reason_code is None:
179
+ return None
180
+ return reason_code if reason_code in REASON_CODES else OTHER_REASON_CODE
@@ -0,0 +1,115 @@
1
+ """Privacy, cardinality, and abuse-resistance policy for AuthWeave telemetry.
2
+
3
+ These limits apply before attributes leave the process. They do not replace
4
+ application-owned Trace Context boundary policy, SDK Views, or Collector
5
+ attribute filtering — they are the library's last line of defense against
6
+ cardinality growth and secret leakage through labels.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from authweave_otel.catalog import AttributeKey
14
+
15
+ #: Maximum number of AuthWeave attributes attached to one span or metric point.
16
+ MAX_ATTRIBUTE_COUNT = 8
17
+
18
+ #: Maximum UTF-8 character length of any AuthWeave attribute value.
19
+ MAX_ATTRIBUTE_VALUE_LENGTH = 64
20
+
21
+ #: Attribute keys the facade is allowed to emit. Anything else is dropped.
22
+ ALLOWED_ATTRIBUTE_KEYS: frozenset[str] = frozenset(key.value for key in AttributeKey)
23
+
24
+ #: Baggage key allowlist. Empty by default: baggage is never extracted or
25
+ #: forwarded by AuthWeave instrumentation.
26
+ DEFAULT_BAGGAGE_KEYS_ALLOWLIST: frozenset[str] = frozenset()
27
+
28
+ #: Substrings that must never appear in emitted attribute values. Used by
29
+ #: secret-canary tests and as documentation of the denial vocabulary.
30
+ SECRET_CANARY_FRAGMENTS: frozenset[str] = frozenset({
31
+ "Bearer ",
32
+ "eyJ", # JWT header prefix
33
+ "cookie=",
34
+ "webhook-signature",
35
+ "dpop ",
36
+ "-----BEGIN",
37
+ "password=",
38
+ "client_secret",
39
+ "authorization_code",
40
+ "account_number",
41
+ "pan=",
42
+ "cvv=",
43
+ })
44
+
45
+
46
+ #: Value substituted when a free-form attribute matches a secret canary.
47
+ REDACTED_ATTRIBUTE_VALUE = "_REDACTED"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class TraceContextPolicy:
52
+ """Deployer-facing Trace Context and baggage policy defaults.
53
+
54
+ AuthWeave never treats a remote parent as trusted identity and never uses
55
+ baggage for authentication, authorization, replay, or idempotency decisions.
56
+ Applications enable W3C Trace Context extraction only on explicitly
57
+ configured boundaries.
58
+ """
59
+
60
+ accept_remote_parent: bool = False
61
+ extract_baggage: bool = False
62
+ forward_baggage: bool = False
63
+ baggage_keys_allowlist: frozenset[str] = DEFAULT_BAGGAGE_KEYS_ALLOWLIST
64
+
65
+
66
+ #: Documented library default. Applications may construct a stricter policy;
67
+ #: loosening requires an explicit deployer decision outside this package.
68
+ DEFAULT_TRACE_CONTEXT_POLICY = TraceContextPolicy()
69
+
70
+
71
+ def truncate_attribute_value(value: str) -> str:
72
+ """Bound one attribute value to :data:`MAX_ATTRIBUTE_VALUE_LENGTH`.
73
+
74
+ Returns:
75
+ The original value when it fits, otherwise a truncated prefix.
76
+ """
77
+ if len(value) <= MAX_ATTRIBUTE_VALUE_LENGTH:
78
+ return value
79
+ return value[:MAX_ATTRIBUTE_VALUE_LENGTH]
80
+
81
+
82
+ def sanitize_attributes(attributes: dict[str, str]) -> dict[str, str]:
83
+ """Drop unknown keys, redact canaries, truncate values, and enforce the budget.
84
+
85
+ Unknown keys are discarded. Values matching
86
+ :data:`SECRET_CANARY_FRAGMENTS` are replaced with
87
+ :data:`REDACTED_ATTRIBUTE_VALUE`. Remaining values longer than
88
+ :data:`MAX_ATTRIBUTE_VALUE_LENGTH` are truncated. When more than
89
+ :data:`MAX_ATTRIBUTE_COUNT` attributes remain after filtering, excess entries
90
+ are dropped in insertion order so cardinality stays bounded.
91
+
92
+ Returns:
93
+ A new dict safe to attach to a span or metric data point.
94
+ """
95
+ sanitized: dict[str, str] = {}
96
+ for key, value in attributes.items():
97
+ if key not in ALLOWED_ATTRIBUTE_KEYS:
98
+ continue
99
+ if contains_secret_canary(value):
100
+ sanitized[key] = REDACTED_ATTRIBUTE_VALUE
101
+ else:
102
+ sanitized[key] = truncate_attribute_value(value)
103
+ if len(sanitized) >= MAX_ATTRIBUTE_COUNT:
104
+ break
105
+ return sanitized
106
+
107
+
108
+ def contains_secret_canary(value: str) -> bool:
109
+ """Return whether ``value`` matches a documented secret-canary fragment.
110
+
111
+ Returns:
112
+ ``True`` when any fragment in :data:`SECRET_CANARY_FRAGMENTS` appears.
113
+ """
114
+ lowered = value.lower()
115
+ return any(fragment.lower() in lowered for fragment in SECRET_CANARY_FRAGMENTS)
File without changes
@@ -0,0 +1,448 @@
1
+ """OpenTelemetry API-only security observability facade.
2
+
3
+ The facade depends only on ``opentelemetry-api``. Without a configured SDK the
4
+ API returns no-op tracers, meters, and instruments, so instrumentation is safe
5
+ and free. The application owns the ``TracerProvider``/``MeterProvider``, SDK,
6
+ Collector, sampling, export, and retention; this library never installs a global
7
+ SDK, exporter, sampler, or ``service.name``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextlib import contextmanager
13
+ from time import perf_counter
14
+ from typing import TYPE_CHECKING
15
+
16
+ from authweave_core import TraceCorrelation
17
+ from opentelemetry import metrics, trace
18
+ from opentelemetry.trace import Link, SpanContext, SpanKind, TraceFlags
19
+ from opentelemetry.trace.status import Status, StatusCode
20
+
21
+ from authweave_otel.catalog import (
22
+ ERROR_OUTCOMES,
23
+ INSTRUMENTATION_SCOPE,
24
+ METRIC_CATALOG,
25
+ SEMANTIC_CONVENTIONS_SCHEMA_URL,
26
+ AttributeKey,
27
+ Instrument,
28
+ Operation,
29
+ Outcome,
30
+ normalize_reason_code,
31
+ span_name,
32
+ )
33
+ from authweave_otel.privacy import sanitize_attributes
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable, Iterator, Sequence
37
+
38
+ from opentelemetry.metrics import CallbackOptions, Counter, Histogram, Meter, MeterProvider, Observation
39
+ from opentelemetry.trace import Span, TracerProvider
40
+
41
+ __version__ = "7.1.2"
42
+
43
+ type AttributeValue = str
44
+ type Attributes = dict[str, AttributeValue]
45
+ type KeyAgeCallback = Callable[[CallbackOptions], Sequence[Observation]]
46
+
47
+
48
+ class OperationScope:
49
+ """Handle for enriching one in-flight AuthWeave security span."""
50
+
51
+ __slots__ = (
52
+ "_credential_kind",
53
+ "_operation",
54
+ "_outcome",
55
+ "_principal_kind",
56
+ "_profile",
57
+ "_reason_code",
58
+ "_span",
59
+ "_unexpected",
60
+ )
61
+
62
+ def __init__(
63
+ self,
64
+ span: Span,
65
+ operation: Operation,
66
+ *,
67
+ profile: str | None,
68
+ principal_kind: str | None,
69
+ credential_kind: str | None,
70
+ ) -> None:
71
+ """Bind the scope to its span and low-cardinality context."""
72
+ self._span = span
73
+ self._operation = operation
74
+ self._profile = profile
75
+ self._principal_kind = principal_kind
76
+ self._credential_kind = credential_kind
77
+ self._outcome: Outcome | None = None
78
+ self._reason_code: str | None = None
79
+ self._unexpected = False
80
+
81
+ def set_outcome(self, outcome: Outcome, *, reason_code: str | None = None) -> None:
82
+ """Record the terminal outcome and an optional bounded reason code."""
83
+ self._outcome = outcome
84
+ self._reason_code = reason_code
85
+
86
+ def record_unexpected_exception(self, exc: BaseException) -> None:
87
+ """Record only the sanitized exception type, never its message or stack."""
88
+ self._unexpected = True
89
+ self._span.add_event("exception", {"exception.type": type(exc).__name__})
90
+
91
+ def _finalize(self) -> None:
92
+ attributes: Attributes = {AttributeKey.OPERATION.value: self._operation.value}
93
+ for key, value in (
94
+ (AttributeKey.PROFILE, self._profile),
95
+ (AttributeKey.PRINCIPAL_KIND, self._principal_kind),
96
+ (AttributeKey.CREDENTIAL_KIND, self._credential_kind),
97
+ ):
98
+ if value is not None:
99
+ attributes[key.value] = value
100
+ if self._outcome is not None:
101
+ attributes[AttributeKey.OUTCOME.value] = self._outcome.value
102
+ normalized = normalize_reason_code(self._reason_code)
103
+ if normalized is not None:
104
+ attributes[AttributeKey.REASON_CODE.value] = normalized
105
+ for key, value in sanitize_attributes(attributes).items():
106
+ self._span.set_attribute(key, value)
107
+ if self._unexpected or (self._outcome is not None and self._outcome in ERROR_OUTCOMES):
108
+ self._span.set_status(Status(StatusCode.ERROR))
109
+
110
+
111
+ class AuthWeaveTelemetry:
112
+ """Typed telemetry facade over the pinned AuthWeave span and metric catalog."""
113
+
114
+ __slots__ = ("_counters", "_histograms", "_meter", "_metrics_enabled", "_spans_enabled", "_tracer")
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ tracer_provider: TracerProvider | None = None,
120
+ meter_provider: MeterProvider | None = None,
121
+ spans_enabled: bool = True,
122
+ metrics_enabled: bool = True,
123
+ ) -> None:
124
+ """Acquire scoped tracer/meter and eagerly build the catalog instruments."""
125
+ self._spans_enabled = spans_enabled
126
+ self._metrics_enabled = metrics_enabled
127
+ self._tracer = trace.get_tracer(
128
+ INSTRUMENTATION_SCOPE,
129
+ __version__,
130
+ tracer_provider,
131
+ SEMANTIC_CONVENTIONS_SCHEMA_URL,
132
+ )
133
+ self._counters: dict[str, Counter] = {}
134
+ self._histograms: dict[str, Histogram] = {}
135
+ self._meter: Meter | None = None
136
+ if metrics_enabled:
137
+ self._build_instruments(meter_provider)
138
+
139
+ def _build_instruments(self, meter_provider: MeterProvider | None) -> None:
140
+ meter = metrics.get_meter(
141
+ INSTRUMENTATION_SCOPE,
142
+ __version__,
143
+ meter_provider,
144
+ SEMANTIC_CONVENTIONS_SCHEMA_URL,
145
+ )
146
+ self._meter = meter
147
+ for spec in METRIC_CATALOG:
148
+ if spec.instrument is Instrument.COUNTER:
149
+ self._counters[spec.name] = meter.create_counter(spec.name, spec.unit, spec.description)
150
+ elif spec.instrument is Instrument.HISTOGRAM:
151
+ self._histograms[spec.name] = meter.create_histogram(spec.name, spec.unit, spec.description)
152
+ # Observable instruments (key.age) are registered with an application
153
+ # callback in a dedicated API and are not built eagerly here.
154
+
155
+ @contextmanager
156
+ def operation_span(
157
+ self,
158
+ operation: Operation,
159
+ *,
160
+ profile: str | None = None,
161
+ principal_kind: str | None = None,
162
+ credential_kind: str | None = None,
163
+ links: Sequence[TraceCorrelation] = (),
164
+ ) -> Iterator[OperationScope]:
165
+ """Run one INTERNAL security span, recording only sanitized exceptions.
166
+
167
+ Cancellation propagates untouched; only ``Exception`` subclasses are
168
+ recorded (by sanitized type) before being re-raised.
169
+
170
+ Yields:
171
+ The scope used to set the terminal outcome and reason code.
172
+ """
173
+ if not self._spans_enabled:
174
+ yield OperationScope(
175
+ trace.INVALID_SPAN,
176
+ operation,
177
+ profile=profile,
178
+ principal_kind=principal_kind,
179
+ credential_kind=credential_kind,
180
+ )
181
+ return
182
+ with self._tracer.start_as_current_span(
183
+ span_name(operation),
184
+ kind=SpanKind.INTERNAL,
185
+ links=[_otel_link(link) for link in links],
186
+ record_exception=False,
187
+ set_status_on_exception=False,
188
+ ) as span:
189
+ scope = OperationScope(
190
+ span,
191
+ operation,
192
+ profile=profile,
193
+ principal_kind=principal_kind,
194
+ credential_kind=credential_kind,
195
+ )
196
+ try:
197
+ yield scope
198
+ except Exception as exc:
199
+ scope.record_unexpected_exception(exc)
200
+ raise
201
+ finally:
202
+ scope._finalize() # ruff: ignore[private-member-access] - scope is owned by this facade
203
+
204
+ @contextmanager
205
+ def observe(
206
+ self,
207
+ operation: Operation,
208
+ *,
209
+ profile: str | None = None,
210
+ principal_kind: str | None = None,
211
+ credential_kind: str | None = None,
212
+ links: Sequence[TraceCorrelation] = (),
213
+ ) -> Iterator[OperationScope]:
214
+ """Implement the core observer contract and emit matching metrics.
215
+
216
+ Yields:
217
+ The operation scope used by core/provider flow instrumentation.
218
+ """
219
+ started = perf_counter()
220
+ with self.operation_span(
221
+ operation,
222
+ profile=profile,
223
+ principal_kind=principal_kind,
224
+ credential_kind=credential_kind,
225
+ links=links,
226
+ ) as scope:
227
+ try:
228
+ yield scope
229
+ except Exception:
230
+ scope.set_outcome(Outcome.ERROR)
231
+ raise
232
+ finally:
233
+ if scope._outcome is not None: # ruff: ignore[private-member-access] - owned observer scope
234
+ self._record_observation(scope, perf_counter() - started)
235
+
236
+ @staticmethod
237
+ def current_correlation() -> TraceCorrelation | None:
238
+ """Return the current valid OpenTelemetry span identifiers."""
239
+ context = trace.get_current_span().get_span_context()
240
+ if not context.is_valid:
241
+ return None
242
+ return TraceCorrelation(trace_id=f"{context.trace_id:032x}", span_id=f"{context.span_id:016x}")
243
+
244
+ def _record_observation(self, scope: OperationScope, duration_seconds: float) -> None:
245
+ operation = scope._operation # ruff: ignore[private-member-access] - owned observer scope
246
+ outcome = scope._outcome # ruff: ignore[private-member-access] - owned observer scope
247
+ if outcome is None:
248
+ return
249
+ if operation is Operation.AUTHENTICATE:
250
+ self.record_authentication(
251
+ profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
252
+ outcome=outcome,
253
+ reason_code=scope._reason_code, # ruff: ignore[private-member-access] - owned observer scope
254
+ principal_kind=scope._principal_kind, # ruff: ignore[private-member-access] - owned observer scope
255
+ credential_kind=scope._credential_kind, # ruff: ignore[private-member-access] - owned observer scope
256
+ duration_seconds=duration_seconds,
257
+ )
258
+ elif operation in {
259
+ Operation.VERIFY_DPOP,
260
+ Operation.VERIFY_HTTP_SIGNATURE,
261
+ Operation.VERIFY_WEBHOOK,
262
+ Operation.VALIDATE_SPIFFE_SVID,
263
+ }:
264
+ self.record_integrity(
265
+ operation=operation,
266
+ profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
267
+ outcome=outcome,
268
+ reason_code=scope._reason_code, # ruff: ignore[private-member-access] - owned observer scope
269
+ credential_kind=scope._credential_kind, # ruff: ignore[private-member-access] - owned observer scope
270
+ duration_seconds=duration_seconds,
271
+ )
272
+ elif operation is Operation.REPLAY_CHECK:
273
+ self.record_replay(profile=scope._profile, outcome=outcome) # ruff: ignore[private-member-access]
274
+ elif operation is Operation.WEBHOOK_DELIVER:
275
+ self.record_webhook_delivery(
276
+ profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
277
+ outcome=outcome,
278
+ duration_seconds=duration_seconds,
279
+ )
280
+ else:
281
+ self.record_remote_operation(
282
+ operation=operation,
283
+ outcome=outcome,
284
+ profile=scope._profile, # ruff: ignore[private-member-access] - owned observer scope
285
+ duration_seconds=duration_seconds,
286
+ )
287
+
288
+ def record_authentication( # ruff: ignore[too-many-arguments] - each parameter is an explicit bounded label
289
+ self,
290
+ *,
291
+ profile: str | None,
292
+ outcome: Outcome,
293
+ reason_code: str | None = None,
294
+ principal_kind: str | None = None,
295
+ credential_kind: str | None = None,
296
+ duration_seconds: float | None = None,
297
+ ) -> None:
298
+ """Record one authentication attempt and optional latency."""
299
+ attributes = _build_attributes(
300
+ profile=profile,
301
+ operation=Operation.AUTHENTICATE,
302
+ outcome=outcome,
303
+ reason_code=reason_code,
304
+ principal_kind=principal_kind,
305
+ credential_kind=credential_kind,
306
+ )
307
+ self._add("authweave.authentication.attempts", attributes)
308
+ self._record("authweave.authentication.duration", duration_seconds, attributes)
309
+
310
+ def record_integrity( # ruff: ignore[too-many-arguments] - each parameter is an explicit bounded label
311
+ self,
312
+ *,
313
+ operation: Operation,
314
+ profile: str | None,
315
+ outcome: Outcome,
316
+ reason_code: str | None = None,
317
+ credential_kind: str | None = None,
318
+ duration_seconds: float | None = None,
319
+ ) -> None:
320
+ """Record one message-integrity verification and optional latency."""
321
+ attributes = _build_attributes(
322
+ profile=profile,
323
+ operation=operation,
324
+ outcome=outcome,
325
+ reason_code=reason_code,
326
+ principal_kind=None,
327
+ credential_kind=credential_kind,
328
+ )
329
+ self._add("authweave.integrity.verifications", attributes)
330
+ self._record("authweave.integrity.duration", duration_seconds, attributes)
331
+
332
+ def record_replay(self, *, profile: str | None, outcome: Outcome) -> None:
333
+ """Record one replay/nonce store decision."""
334
+ attributes = _build_attributes(
335
+ profile=profile,
336
+ operation=Operation.REPLAY_CHECK,
337
+ outcome=outcome,
338
+ reason_code=None,
339
+ principal_kind=None,
340
+ credential_kind=None,
341
+ )
342
+ self._add("authweave.replay.decisions", attributes)
343
+
344
+ def record_remote_operation(
345
+ self,
346
+ *,
347
+ operation: Operation,
348
+ outcome: Outcome,
349
+ profile: str | None = None,
350
+ duration_seconds: float | None = None,
351
+ ) -> None:
352
+ """Record one remote dependency attempt and optional latency, never a URL."""
353
+ attributes = _build_attributes(
354
+ profile=profile,
355
+ operation=operation,
356
+ outcome=outcome,
357
+ reason_code=None,
358
+ principal_kind=None,
359
+ credential_kind=None,
360
+ )
361
+ self._add("authweave.remote.operation.attempts", attributes)
362
+ self._record("authweave.remote.operation.duration", duration_seconds, attributes)
363
+
364
+ def record_cache(self, *, operation: Operation, outcome: Outcome) -> None:
365
+ """Record one cache hit, miss, stale, or error decision."""
366
+ attributes = _build_attributes(
367
+ profile=None,
368
+ operation=operation,
369
+ outcome=outcome,
370
+ reason_code=None,
371
+ principal_kind=None,
372
+ credential_kind=None,
373
+ )
374
+ self._add("authweave.cache.requests", attributes)
375
+
376
+ def record_webhook_delivery(
377
+ self,
378
+ *,
379
+ profile: str | None,
380
+ outcome: Outcome,
381
+ duration_seconds: float | None = None,
382
+ ) -> None:
383
+ """Record one webhook delivery attempt and optional end-to-end latency."""
384
+ attributes = _build_attributes(
385
+ profile=profile,
386
+ operation=Operation.WEBHOOK_DELIVER,
387
+ outcome=outcome,
388
+ reason_code=None,
389
+ principal_kind=None,
390
+ credential_kind=None,
391
+ )
392
+ self._add("authweave.webhook.delivery.attempts", attributes)
393
+ self._record("authweave.webhook.delivery.duration", duration_seconds, attributes)
394
+
395
+ def register_key_age(self, callback: KeyAgeCallback) -> None:
396
+ """Register the application callback backing the ``authweave.key.age`` gauge.
397
+
398
+ The callback must return bounded, low-cardinality observations and never a
399
+ key identifier or URL. It is a no-op when metrics are disabled.
400
+ """
401
+ if not self._metrics_enabled or self._meter is None:
402
+ return
403
+ spec = next(spec for spec in METRIC_CATALOG if spec.name == "authweave.key.age")
404
+ self._meter.create_observable_gauge(spec.name, [callback], spec.unit, spec.description)
405
+
406
+ def _add(self, name: str, attributes: Attributes) -> None:
407
+ if self._metrics_enabled:
408
+ self._counters[name].add(1, attributes)
409
+
410
+ def _record(self, name: str, value: float | None, attributes: Attributes) -> None:
411
+ if self._metrics_enabled and value is not None:
412
+ self._histograms[name].record(value, attributes)
413
+
414
+
415
+ def _build_attributes( # ruff: ignore[too-many-arguments] - each label is an explicit bounded dimension
416
+ *,
417
+ profile: str | None,
418
+ operation: Operation,
419
+ outcome: Outcome,
420
+ reason_code: str | None,
421
+ principal_kind: str | None,
422
+ credential_kind: str | None,
423
+ ) -> Attributes:
424
+ attributes: Attributes = {
425
+ AttributeKey.OPERATION.value: operation.value,
426
+ AttributeKey.OUTCOME.value: outcome.value,
427
+ }
428
+ normalized = normalize_reason_code(reason_code)
429
+ for key, value in (
430
+ (AttributeKey.PROFILE, profile),
431
+ (AttributeKey.REASON_CODE, normalized),
432
+ (AttributeKey.PRINCIPAL_KIND, principal_kind),
433
+ (AttributeKey.CREDENTIAL_KIND, credential_kind),
434
+ ):
435
+ if value is not None:
436
+ attributes[key.value] = value
437
+ return sanitize_attributes(attributes)
438
+
439
+
440
+ def _otel_link(correlation: TraceCorrelation) -> Link:
441
+ return Link(
442
+ SpanContext(
443
+ trace_id=int(correlation.trace_id, 16),
444
+ span_id=int(correlation.span_id, 16),
445
+ is_remote=True,
446
+ trace_flags=TraceFlags(TraceFlags.DEFAULT),
447
+ ),
448
+ )
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "authweave-otel"
3
+ version = "7.1.2"
4
+ description = "OpenTelemetry API-only security observability facade for the AuthWeave stack"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ {name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
10
+ ]
11
+ maintainers = [
12
+ {name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
13
+ ]
14
+ keywords = ["opentelemetry", "observability", "authentication", "security", "metrics", "tracing"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Security",
19
+ "Topic :: System :: Monitoring",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ ]
26
+ requires-python = "<3.15.0,>=3.12.0"
27
+ dependencies = [
28
+ "authweave-core==7.1.2",
29
+ "opentelemetry-api>=1.44.0,<2.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ homepage = "https://github.com/ZYLVEXT/litestar-auth"
34
+ documentation = "https://zylvext.github.io/litestar-auth/"
35
+ source = "https://github.com/ZYLVEXT/litestar-auth"
36
+ tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
37
+
38
+ [build-system]
39
+ requires = ["hatchling==1.31.0"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["authweave_otel"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ only-include = ["authweave_otel"]
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "opentelemetry-sdk>=1.44.0,<2.0",
51
+ ]
52
+
53
+ [tool.coverage.run]
54
+ source = ["authweave_otel"]
55
+ branch = true
56
+
57
+ [tool.coverage.report]
58
+ fail_under = 100
59
+ show_missing = true
60
+
61
+ [tool.ty.environment]
62
+ python-version = "3.12"
63
+
64
+ [tool.ty.src]
65
+ include = ["authweave_otel", "tests"]