otto 2.9.0 → 2.10.0
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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +146 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +181 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Privacy-preserving request data
|
|
2
|
+
|
|
3
|
+
Otto's default request posture is to reduce the precision of public client
|
|
4
|
+
information before application code, logging, authentication, rate limiting,
|
|
5
|
+
and other middleware see it. The privacy pipeline is a Rack concern, not only a
|
|
6
|
+
router feature: if an outer Rack middleware logs the request before Otto runs,
|
|
7
|
+
place the privacy middleware in the common stack first.
|
|
8
|
+
|
|
9
|
+
## Default behavior
|
|
10
|
+
|
|
11
|
+
With the default `:masked` profile:
|
|
12
|
+
|
|
13
|
+
- public IP addresses are masked by the configured octet precision (one octet
|
|
14
|
+
by default: `203.0.113.9` becomes `203.0.113.0`);
|
|
15
|
+
- requests from private and loopback addresses are exempt from the privacy
|
|
16
|
+
fingerprint by default, so their IP, user agent, and referer remain unchanged;
|
|
17
|
+
- for requests that are masked, user-agent version details are anonymized and
|
|
18
|
+
referer query parameters are removed;
|
|
19
|
+
- original public values are not retained in the Rack environment; and
|
|
20
|
+
- country resolution is country-level only and returns `**` when enabled but no
|
|
21
|
+
configured source answers.
|
|
22
|
+
|
|
23
|
+
Downstream code should read `req.ip`, `req.masked_ip`, and the documented privacy
|
|
24
|
+
environment keys rather than re-resolving an address from forwarded headers.
|
|
25
|
+
|
|
26
|
+
These profiles are technical data-minimization controls, not a compliance
|
|
27
|
+
certification. Whether a deployment meets GDPR, CCPA, or another legal regime
|
|
28
|
+
also depends on its purposes, notices, retention, access controls, vendors, and
|
|
29
|
+
jurisdiction.
|
|
30
|
+
|
|
31
|
+
## Profiles
|
|
32
|
+
|
|
33
|
+
Choose a named profile when the deployment posture should be obvious in review:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
otto = Otto.new('routes')
|
|
37
|
+
otto.configure_ip_privacy(profile: :masked) # default
|
|
38
|
+
# or:
|
|
39
|
+
otto.configure_ip_privacy(profile: :anonymous) # mask private/localhost too
|
|
40
|
+
# or:
|
|
41
|
+
otto.configure_ip_privacy(profile: :audit) # disable IP privacy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Profile | Public IPs | Private/localhost IPs | Use when |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `:masked` | Masked | Exempt by default | General privacy-by-default deployments and local development. |
|
|
47
|
+
| `:anonymous` | Masked | Masked | Internal addresses must also be treated as identifying data. |
|
|
48
|
+
| `:audit` | Not masked | Not masked | The operator has deliberately accepted raw-IP retention and controls logs and downstream systems. |
|
|
49
|
+
|
|
50
|
+
The `:audit` profile transfers retention responsibility to the operator. It is
|
|
51
|
+
not a way to obtain precise matching while keeping privacy enabled; use
|
|
52
|
+
`env['otto.ip_match']` for that narrower need.
|
|
53
|
+
|
|
54
|
+
Configuration is boot-time only. The first request freezes configuration in
|
|
55
|
+
normal operation, so set profiles, trusted proxies, database readers, and other
|
|
56
|
+
privacy settings before serving traffic.
|
|
57
|
+
|
|
58
|
+
## Privacy-safe request values
|
|
59
|
+
|
|
60
|
+
Inside a handler:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class Analytics
|
|
64
|
+
def self.record(req, res)
|
|
65
|
+
event = {
|
|
66
|
+
ip: req.ip,
|
|
67
|
+
country: req.geo_country,
|
|
68
|
+
asn: req.asn,
|
|
69
|
+
anonymizer: req.anonymizer,
|
|
70
|
+
user_agent: req.user_agent,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
AuditLog.write(event)
|
|
74
|
+
res.status = 204
|
|
75
|
+
res.body = []
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Common values are also available in the Rack environment:
|
|
81
|
+
|
|
82
|
+
| Value | Environment key | Contract |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| Canonical client IP | `otto.client_ip` | Masked IP when masking applies; resolved full IP under `:audit` or for an exempt private/loopback request. |
|
|
85
|
+
| Precise CIDR verdict | `otto.ip_match` | Callable that checks the resolved full IP against CIDRs and returns only `true` or `false`. |
|
|
86
|
+
| Masked IP | `otto.privacy.masked_ip` | Set when the request runs through the privacy fingerprint; absent for exempt or `:audit` requests. |
|
|
87
|
+
| Rotating IP hash | `otto.privacy.hashed_ip` | Correlation value computed with Otto's rotating key; absent for exempt or `:audit` requests. |
|
|
88
|
+
| Stable correlation hash | `otto.privacy.correlation_hash` | HMAC value when `correlation_secret:` is configured; otherwise `nil`. |
|
|
89
|
+
| Country | `otto.privacy.geo_country` | ISO 3166-1 alpha-2 code, `**` when enabled but unresolved, or `nil` when geo/privacy is disabled. |
|
|
90
|
+
| ASN | `otto.privacy.asn` | `nil` when off, `**` when enabled but unresolved, or a value such as `AS15169`. |
|
|
91
|
+
| Anonymizer | `otto.privacy.anonymizer` | `nil` when off, `**` when no database answers, or a classification label. |
|
|
92
|
+
|
|
93
|
+
`req.hashed_ip` is designed for short-lived correlation using a rotating key.
|
|
94
|
+
For long-lived correlation, explicitly configure a stable secret and protect
|
|
95
|
+
that secret as sensitive configuration. Changing the secret changes every
|
|
96
|
+
correlation hash.
|
|
97
|
+
|
|
98
|
+
## Country, ASN, and anonymizer data
|
|
99
|
+
|
|
100
|
+
These signals have different trust and precision models:
|
|
101
|
+
|
|
102
|
+
- [Geo-country resolution](geo-country.md) documents trusted provider headers,
|
|
103
|
+
CIDR trusted-proxy requirements, masked MMDB lookup, and the unknown sentinel.
|
|
104
|
+
- [ASN and anonymizer enrichment](enrichment.md) documents the opt-in database
|
|
105
|
+
contracts. ASN uses a masked address; anonymizer classification deliberately
|
|
106
|
+
uses the unmasked address internally and emits only a label.
|
|
107
|
+
|
|
108
|
+
Enable optional signals explicitly:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
otto.configure_ip_privacy(
|
|
112
|
+
geo: true,
|
|
113
|
+
geo_db_path: 'data/country.mmdb',
|
|
114
|
+
asn: true,
|
|
115
|
+
asn_db_path: 'data/origin-asn.mmdb',
|
|
116
|
+
anonymizer: true,
|
|
117
|
+
anonymizer_db_path: 'data/anonymizer.mmdb'
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The `maxmind-db` gem is optional and is required only when a database path is
|
|
122
|
+
configured. A reader object responding to `#get(ip)` can be injected instead.
|
|
123
|
+
Database paths are opened at configuration time; invalid paths fail during boot
|
|
124
|
+
rather than on an arbitrary request.
|
|
125
|
+
|
|
126
|
+
## Trusted proxy and matching boundaries
|
|
127
|
+
|
|
128
|
+
Configure trusted proxies before relying on forwarded client information:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
otto = Otto.new(
|
|
132
|
+
'routes',
|
|
133
|
+
trusted_proxies: ['10.0.0.0/8', '192.0.2.0/24']
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
CIDR-based trust lets Otto verify the proxy peer. Count-based
|
|
138
|
+
`trusted_proxy_depth` is a separate mode and does not make geo headers
|
|
139
|
+
trustworthy. A configured `geo_header` combined with depth mode is rejected at
|
|
140
|
+
configuration time; use a local database in depth-mode deployments instead.
|
|
141
|
+
|
|
142
|
+
The same trust decision also gates the forwarded host, scheme, and port headers
|
|
143
|
+
that `Rack::Request#host` reads. See
|
|
144
|
+
[Forwarded host authority](forwarded-authority.md), including the explicit
|
|
145
|
+
`trusted_proxies: :none` assertion for directly exposed applications.
|
|
146
|
+
|
|
147
|
+
For precise access control without exposing the address to application code,
|
|
148
|
+
call the automatically installed `env['otto.ip_match']` capability. It matches
|
|
149
|
+
the resolved full client IP against application CIDRs and returns only
|
|
150
|
+
`true`/`false`; it fails closed when no client IP resolves. Do not log or persist
|
|
151
|
+
the closure.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
allowed = req.env.fetch('otto.ip_match').call(['192.0.2.0/24', '2001:db8::/32'])
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Middleware placement
|
|
158
|
+
|
|
159
|
+
When building a larger Rack stack, put privacy before components that log or
|
|
160
|
+
inspect the request:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
builder.use Otto::Security::Middleware::IPPrivacyMiddleware, otto.security_config
|
|
164
|
+
builder.use Rack::CommonLogger
|
|
165
|
+
builder.use Sentry::Rack::CaptureExceptions
|
|
166
|
+
# Then mount the Otto application.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Otto also installs its privacy middleware internally. The outer common-stack
|
|
170
|
+
placement is needed when middleware outside the Otto app would otherwise see the
|
|
171
|
+
raw peer first.
|
|
172
|
+
|
|
173
|
+
Pass `otto.security_config` as shown. The outer instance resolves `otto.client_ip`
|
|
174
|
+
first and the inner one then short-circuits, so an outer instance constructed
|
|
175
|
+
without the configuration would silently apply defaults instead of the
|
|
176
|
+
application's profile, precision, correlation secret, and enrichment settings.
|
|
177
|
+
Proxy trust is the exception: the inner instance re-applies its own trust
|
|
178
|
+
posture even after an outer pass, and a CIDR configuration that can no longer
|
|
179
|
+
see the connecting peer fails closed. See
|
|
180
|
+
[Forwarded host authority](forwarded-authority.md#place-the-middleware-before-other-request-consumers).
|
|
181
|
+
|
|
182
|
+
## Operational rules
|
|
183
|
+
|
|
184
|
+
- Treat `nil`, `'**'`, `'none'`, and a real label as different states. In
|
|
185
|
+
particular, anonymizer `'none'` means the database was consulted and did not
|
|
186
|
+
list the address; `'**'` means no database answered.
|
|
187
|
+
- Do not use provider geo headers without a verifiable CIDR trusted proxy. A
|
|
188
|
+
client can otherwise submit its own country.
|
|
189
|
+
- Do not confuse the masked client IP with the raw peer used to authenticate a
|
|
190
|
+
local network service. Integrations such as Caddy TLS have their own trust
|
|
191
|
+
boundary.
|
|
192
|
+
- Test logs and monitoring outside Otto's stack. Privacy is only effective if
|
|
193
|
+
those components receive the masked environment first.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Routing applications with Otto
|
|
2
|
+
|
|
3
|
+
Otto keeps the application boundary small: a plain-text route file maps an HTTP
|
|
4
|
+
verb and path to a Ruby handler. Use this guide to choose a handler style and
|
|
5
|
+
response contract. The exact route grammar is in the [route syntax reference](../reference/route-syntax.md).
|
|
6
|
+
|
|
7
|
+
## Choose a handler style
|
|
8
|
+
|
|
9
|
+
| Use this when | Route target | Invocation |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| You need a class method with direct Rack access | `App.index` | `App.index(req, res)` |
|
|
12
|
+
| You need an object with direct Rack access | `App#show` | `App.new(req, res).show` |
|
|
13
|
+
| You want a constrained, testable application operation | `App::Operation` | `App::Operation.new(strategy_result, params, locale)` |
|
|
14
|
+
| You need a small pre-registered endpoint function | `&name` | `call(req, res, captured_path_params)` |
|
|
15
|
+
|
|
16
|
+
## Controller-style handlers
|
|
17
|
+
|
|
18
|
+
Routes can call a class method or instantiate a class for an instance method:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
GET / App.index
|
|
22
|
+
GET /products/:id App#show
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class App
|
|
27
|
+
def initialize(req, res)
|
|
28
|
+
@req = req
|
|
29
|
+
@res = res
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def show
|
|
33
|
+
@res.body = "Product: #{@req.params[:id]}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.index(req, res)
|
|
37
|
+
res['content-type'] = 'text/plain'
|
|
38
|
+
res.body = 'Hello Otto'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use this style when the handler needs cookies, request headers, the Rack
|
|
44
|
+
request object, or direct response helpers.
|
|
45
|
+
|
|
46
|
+
## Logic classes
|
|
47
|
+
|
|
48
|
+
Use a bare class target for an operation with an explicit input context:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
GET /products/:id Products::Show auth=session response=json
|
|
52
|
+
POST /products Products::Create auth=session response=json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Products::Show
|
|
57
|
+
def initialize(strategy_result, params, locale)
|
|
58
|
+
@context = strategy_result
|
|
59
|
+
@params = params
|
|
60
|
+
@locale = locale
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def raise_concerns
|
|
64
|
+
@product = Product.find(@params[:id])
|
|
65
|
+
unless @product.public? || @product.owner_id == @context.user_id
|
|
66
|
+
raise Otto::Security::AuthorizationError, 'Product access denied'
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def process
|
|
71
|
+
{ id: @product.id, name: @product.name, locale: @locale }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Otto runs `raise_concerns` before `process` when those methods exist. Put
|
|
77
|
+
resource loading and resource-level authorization in `raise_concerns`; route
|
|
78
|
+
authentication and broad role checks belong in the route definition.
|
|
79
|
+
|
|
80
|
+
Logic classes do not receive the Rack environment. This keeps their inputs
|
|
81
|
+
explicit and prevents application operations from depending on ambient request
|
|
82
|
+
state. Choose a controller-style handler when direct request access is part of
|
|
83
|
+
the operation.
|
|
84
|
+
|
|
85
|
+
## Registered lambda handlers
|
|
86
|
+
|
|
87
|
+
Lambda routes are useful for small endpoints that do not need a Ruby constant or
|
|
88
|
+
handler class. Register the callable at boot:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
otto = Otto.new('routes', lambda_handlers: {
|
|
92
|
+
health_check: lambda do |_req, res, _extra_params|
|
|
93
|
+
res['content-type'] = 'text/plain'
|
|
94
|
+
res.body = 'ok'
|
|
95
|
+
end,
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
GET /health &health_check
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The registry is normalized and frozen during configuration. A lambda must
|
|
104
|
+
accept three positional arguments: request, response, and captured path
|
|
105
|
+
parameters. Query and form parameters remain available through `req.params`.
|
|
106
|
+
The route name is an exact registry key; it is not evaluated as Ruby code.
|
|
107
|
+
|
|
108
|
+
## Response selection
|
|
109
|
+
|
|
110
|
+
Use `response=` when the handler returns a value that should pass through Otto's
|
|
111
|
+
response handling:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
GET /api/products Products::Index response=json
|
|
115
|
+
GET /dashboard Dashboard#show response=view
|
|
116
|
+
POST /login Sessions#create response=redirect
|
|
117
|
+
GET /data Data#show response=auto
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`response=default` is the default. Keep response selection in the route file
|
|
121
|
+
so the HTTP contract is visible beside the endpoint.
|
|
122
|
+
|
|
123
|
+
| Response type | Handler contract |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `default` | Mutate `res` directly. The handler's return value is ignored. |
|
|
126
|
+
| `json` | Return a Hash for direct JSON serialization. `nil` becomes `{ "success": true }`; another value is wrapped as `data`. A Logic class may instead provide `response_data`. |
|
|
127
|
+
| `view` | Return a value rendered with `to_s`, or provide `view.render` on a Logic object. |
|
|
128
|
+
| `redirect` | Return a path String, or provide `redirect_path` on a Logic object. The fallback path is `/`. |
|
|
129
|
+
| `auto` | A Hash becomes JSON, a path-like String becomes a redirect, and a Logic object with `view` uses the view handler; other results use default behavior. |
|
|
130
|
+
|
|
131
|
+
An unknown response name currently falls back to `default`. Treat response
|
|
132
|
+
names as a fixed set; a typo otherwise changes the route to direct-response
|
|
133
|
+
behavior.
|
|
134
|
+
|
|
135
|
+
## Route parameters
|
|
136
|
+
|
|
137
|
+
Named path segments are available in request parameters:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
GET /products/:id Products::Show
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A handler can read `req.params[:id]` or a Logic class can read `params[:id]`.
|
|
144
|
+
Request query and body parameters are merged according to the handler's request
|
|
145
|
+
contract. JSON bodies are parsed for Logic-class parameters when the content
|
|
146
|
+
type is JSON and the body is a JSON object. A valid non-object JSON body is
|
|
147
|
+
ignored. Malformed JSON is logged and the Logic class still runs with its other
|
|
148
|
+
parameters; perform application validation when malformed JSON must return a
|
|
149
|
+
client error.
|
|
150
|
+
|
|
151
|
+
## Security options in routes
|
|
152
|
+
|
|
153
|
+
Authentication, roles, and CSRF exemptions are explicit route options:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
GET /profile Profile#show auth=session
|
|
157
|
+
GET /admin Admin::Dashboard auth=session role=admin
|
|
158
|
+
POST /hook Hooks#receive csrf=exempt
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Malformed `auth`, `role`, and `csrf` tokens fail route parsing rather than
|
|
162
|
+
silently weakening the route. Do not use `csrf=exempt` as a general API switch;
|
|
163
|
+
choose an independent request-authentication and replay-protection model for
|
|
164
|
+
webhooks or other non-browser endpoints.
|
|
165
|
+
|
|
166
|
+
## Configuration timing
|
|
167
|
+
|
|
168
|
+
Construct and configure the Otto instance before the first request:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
otto = Otto.new('routes')
|
|
172
|
+
otto.add_auth_strategy(
|
|
173
|
+
'session',
|
|
174
|
+
Otto::Security::Authentication::Strategies::SessionStrategy.new
|
|
175
|
+
)
|
|
176
|
+
otto.register_request_helpers(MyApp::RequestHelpers)
|
|
177
|
+
# Add middleware and other boot-time options here.
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
In normal operation, the first request freezes configuration. Runtime route or
|
|
181
|
+
security changes are not part of the application contract.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Structured logging
|
|
2
|
+
|
|
3
|
+
Otto provides explicit logging helpers for request context, microsecond timing,
|
|
4
|
+
and backtrace path reduction. The helpers do not sanitize arbitrary metadata;
|
|
5
|
+
callers remain responsible for excluding secrets and personal data.
|
|
6
|
+
|
|
7
|
+
## Logging helpers
|
|
8
|
+
|
|
9
|
+
Otto provides several helper methods for consistent logging:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Request context extraction
|
|
13
|
+
Otto::LoggingHelpers.request_context(env)
|
|
14
|
+
# May include: { method:, path:, ip:, country:, user_agent: }
|
|
15
|
+
|
|
16
|
+
# Timed operation logging
|
|
17
|
+
Otto::LoggingHelpers.log_timed_operation(level, message, env, **metadata) { block }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Logging patterns
|
|
21
|
+
|
|
22
|
+
For request-scoped structured logging, use `Otto.structured_log` with
|
|
23
|
+
`LoggingHelpers.request_context(env).merge()`:
|
|
24
|
+
|
|
25
|
+
`request_context(env)` creates a new context hash, and `.merge` creates another
|
|
26
|
+
hash rather than mutating shared state. Treat the request `env` and values inside
|
|
27
|
+
it as request-owned data; the helper does not deep-copy mutable strings.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# Route logging
|
|
31
|
+
Otto.structured_log(:debug, "Route matched",
|
|
32
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
33
|
+
type: 'literal',
|
|
34
|
+
handler: route.route_definition.definition,
|
|
35
|
+
auth_strategy: route.route_definition.auth_requirement || 'none'
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Authentication logging
|
|
40
|
+
Otto.structured_log(:info, "Auth strategy result",
|
|
41
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
42
|
+
strategy: strategy.class.name.split('::').last.downcase.gsub('strategy', ''),
|
|
43
|
+
success: true,
|
|
44
|
+
user_id: result.user_id,
|
|
45
|
+
duration: duration_μs
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For operations that need timing, use `log_timed_operation` which wraps `structured_log` with automatic timing:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Template compilation with timing
|
|
54
|
+
result = Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env,
|
|
55
|
+
template_type: 'handlebars',
|
|
56
|
+
cached: false
|
|
57
|
+
) do
|
|
58
|
+
compile_template(template_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Database operation with timing
|
|
62
|
+
Otto::LoggingHelpers.log_timed_operation(:debug, "User lookup", env,
|
|
63
|
+
user_id: user_id,
|
|
64
|
+
cache_hit: false
|
|
65
|
+
) do
|
|
66
|
+
User.find(user_id)
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Timing conventions
|
|
71
|
+
|
|
72
|
+
Otto uses **microseconds** for all timing measurements via `Otto::Utils.now_in_μs`:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# Manual timing
|
|
76
|
+
start_time = Otto::Utils.now_in_μs
|
|
77
|
+
result = perform_operation()
|
|
78
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
79
|
+
|
|
80
|
+
Otto.structured_log(:info, "Operation completed",
|
|
81
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
82
|
+
operation: 'user_creation',
|
|
83
|
+
duration: duration # Always in microseconds
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Automatic timing with error handling
|
|
88
|
+
Otto::LoggingHelpers.log_timed_operation(:info, "Database query", env,
|
|
89
|
+
table: 'users',
|
|
90
|
+
query_type: 'SELECT'
|
|
91
|
+
) do
|
|
92
|
+
database.execute(query)
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Request fields
|
|
97
|
+
|
|
98
|
+
Request-scoped events should normally merge `request_context(env)`, which adds
|
|
99
|
+
available values for:
|
|
100
|
+
|
|
101
|
+
- **method** - HTTP method (`GET`, `POST`, etc.)
|
|
102
|
+
- **path** - request path
|
|
103
|
+
- **ip** - canonical client IP under the configured privacy profile
|
|
104
|
+
- **country** - resolved country when available
|
|
105
|
+
- **user_agent** - current request user agent, truncated to 100 characters
|
|
106
|
+
|
|
107
|
+
Unavailable values are omitted. Non-request events, such as configuration or
|
|
108
|
+
startup logs, should include only relevant event-specific fields. Timed events
|
|
109
|
+
also include **duration** in microseconds.
|
|
110
|
+
|
|
111
|
+
Additional fields such as `user_id`, `handler`, `error`, or `error_class` may be
|
|
112
|
+
useful, but Otto does not redact them. Do not log credentials, session tokens,
|
|
113
|
+
raw request parameters, or exception messages that may contain secrets.
|
|
114
|
+
|
|
115
|
+
## Error handling in timed operations
|
|
116
|
+
|
|
117
|
+
`log_timed_operation` automatically handles exceptions:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Successful operation
|
|
121
|
+
Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
|
|
122
|
+
compile_template('user')
|
|
123
|
+
end
|
|
124
|
+
# Logs: Template compiled: method=GET path=/users template=user duration=15230
|
|
125
|
+
|
|
126
|
+
# Failed operation (automatic error logging + re-raise)
|
|
127
|
+
Otto::LoggingHelpers.log_timed_operation(:info, "Template compiled", env, template: 'user') do
|
|
128
|
+
raise StandardError, "Template not found"
|
|
129
|
+
end
|
|
130
|
+
# Logs: Template compiled failed: method=GET path=/users template=user duration=1520 error="Template not found" error_class=StandardError
|
|
131
|
+
# Then re-raises the original exception
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Privacy behavior
|
|
135
|
+
|
|
136
|
+
`request_context` prefers `env['otto.client_ip']`, the canonical value produced
|
|
137
|
+
by `IPPrivacyMiddleware`:
|
|
138
|
+
|
|
139
|
+
- Under the normal privacy profiles, public addresses are masked.
|
|
140
|
+
- Under the `:audit` profile or after `disable_ip_privacy!`, the canonical public
|
|
141
|
+
address is raw and `request_context` logs it unchanged.
|
|
142
|
+
- Private and localhost addresses remain raw under the default `:masked` profile;
|
|
143
|
+
the `:anonymous` profile masks them too.
|
|
144
|
+
- Outside Otto's middleware, when `otto.client_ip` is absent,
|
|
145
|
+
`privacy_safe_ip` masks a public `REMOTE_ADDR` using the default precision and
|
|
146
|
+
returns `[redacted]` for an unparseable address.
|
|
147
|
+
- `request_context` truncates the current `HTTP_USER_AGENT` to 100 characters.
|
|
148
|
+
It is anonymized only if the privacy middleware has already anonymized it.
|
|
149
|
+
|
|
150
|
+
The helper does not make arbitrary metadata privacy-safe. Review every field
|
|
151
|
+
added by the caller.
|
|
152
|
+
|
|
153
|
+
## Backtrace path reduction
|
|
154
|
+
|
|
155
|
+
Otto reduces filesystem details in recognized Ruby backtrace lines. This is
|
|
156
|
+
defense in depth, not a confidentiality boundary: custom or unrecognized lines
|
|
157
|
+
are returned unchanged.
|
|
158
|
+
|
|
159
|
+
**Risks of raw backtraces:**
|
|
160
|
+
- Expose absolute paths revealing usernames (`/Users/alice/`, `/home/admin/`)
|
|
161
|
+
- Reveal project structure and internal organization
|
|
162
|
+
- Show gem installation paths and Ruby versions
|
|
163
|
+
- Leak system architecture details
|
|
164
|
+
|
|
165
|
+
**Automatic path reduction in `Otto.structured_log`:**
|
|
166
|
+
|
|
167
|
+
`Otto.structured_log` applies the backtrace sanitizer only when the metadata is
|
|
168
|
+
a hash whose exact `:backtrace` key contains an array. Other fields and arrays
|
|
169
|
+
are passed unchanged.
|
|
170
|
+
|
|
171
|
+
**Path-reduction rules:**
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# Project files → relative paths only
|
|
175
|
+
"/Users/alice/myapp/app/controllers/users_controller.rb:42:in `create'"
|
|
176
|
+
# ↓ SANITIZED TO:
|
|
177
|
+
"app/controllers/users_controller.rb:42:in `create'"
|
|
178
|
+
|
|
179
|
+
# Bundler gems → [GEM] tag with gem name only
|
|
180
|
+
"/Users/alice/.rbenv/versions/3.4.7/lib/ruby/gems/3.4.0/bundler/gems/otto-34f285412a44/lib/otto/route.rb:142"
|
|
181
|
+
# ↓ SANITIZED TO:
|
|
182
|
+
"[GEM] otto/lib/otto/route.rb:142"
|
|
183
|
+
|
|
184
|
+
# Regular gems → [GEM] tag, version stripped
|
|
185
|
+
"/opt/ruby/gems/3.4.0/gems/rack-3.2.4/lib/rack/builder.rb:310"
|
|
186
|
+
# ↓ SANITIZED TO:
|
|
187
|
+
"[GEM] rack/lib/rack/builder.rb:310"
|
|
188
|
+
|
|
189
|
+
# Ruby stdlib → [RUBY] tag with filename only
|
|
190
|
+
"/Users/alice/.rbenv/versions/3.4.7/lib/ruby/3.4.0/logger.rb:310"
|
|
191
|
+
# ↓ SANITIZED TO:
|
|
192
|
+
"[RUBY] logger.rb:310"
|
|
193
|
+
|
|
194
|
+
# Unknown/external → filename only
|
|
195
|
+
"/some/unknown/path/file.rb:50"
|
|
196
|
+
# ↓ SANITIZED TO:
|
|
197
|
+
"[EXTERNAL] file.rb:50"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Usage:**
|
|
201
|
+
|
|
202
|
+
Path reduction happens automatically when using `log_backtrace`. This helper
|
|
203
|
+
logs at `:error` and limits the backtrace to its first 20 lines:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# In error handlers (Otto does this automatically)
|
|
207
|
+
Otto::LoggingHelpers.log_backtrace(error,
|
|
208
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
209
|
+
error_id: error_id,
|
|
210
|
+
handler: 'UserController#create'
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Manual usage if needed
|
|
215
|
+
sanitized = Otto::LoggingHelpers.sanitize_backtrace(error.backtrace)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Anti-patterns
|
|
219
|
+
|
|
220
|
+
**❌ Don't create event classes:**
|
|
221
|
+
```ruby
|
|
222
|
+
# NO - Adds unnecessary abstraction
|
|
223
|
+
event = RouteMatchEvent.new(type: :literal, method: http_verb, path: path)
|
|
224
|
+
Otto.structured_log(event.level, event.message, event.to_h)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**❌ Don't create helper wrappers:**
|
|
228
|
+
```ruby
|
|
229
|
+
# NO - Hides what's being logged
|
|
230
|
+
Otto::Logging.log_route_match(type: :literal, method: http_verb, path: path, env: env)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**❌ Don't mix timing units:**
|
|
234
|
+
```ruby
|
|
235
|
+
# NO - Inconsistent units
|
|
236
|
+
duration_ms = (Otto::Utils.now_in_μs - start_time) / 1000 # Converting to milliseconds
|
|
237
|
+
Otto.structured_log(:info, "Operation done", { duration_ms: duration_ms })
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**✅ Do use explicit inline logging:**
|
|
241
|
+
```ruby
|
|
242
|
+
# YES - Clear, simple, explicit
|
|
243
|
+
Otto.structured_log(:debug, "Route matched",
|
|
244
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
245
|
+
type: 'literal',
|
|
246
|
+
handler: 'App#index'
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# YES - Consistent microsecond timing
|
|
251
|
+
Otto.structured_log(:info, "Operation completed",
|
|
252
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
253
|
+
operation: 'user_lookup',
|
|
254
|
+
duration: Otto::Utils.now_in_μs - start_time
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Output behavior
|
|
260
|
+
|
|
261
|
+
`Otto.logger` defaults to Ruby's `Logger`. With that logger, metadata is rendered
|
|
262
|
+
inside a formatted string:
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
I, [2025-01-21T14:39:39.462833 #82244] INFO -- : [Otto] Template compiled -- {method: "GET", path: "/users", ip: "192.0.2.0", template_type: "handlebars", cached: false, duration: 68}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
For a logger whose level method has a fixed arity greater than one, Otto calls
|
|
269
|
+
that method as `logger.info(message, metadata)`. Other logger APIs use the
|
|
270
|
+
formatted-string fallback. Verify the adapter for a third-party structured
|
|
271
|
+
logger before relying on separately indexed fields; Otto does not ship or test
|
|
272
|
+
a SemanticLogger adapter.
|
|
273
|
+
|
|
274
|
+
## Rationale
|
|
275
|
+
|
|
276
|
+
- **Simplicity**: Direct logging calls are easier to understand than abstraction layers
|
|
277
|
+
- **Explicitness**: You can see exactly what's being logged at the call site
|
|
278
|
+
- **Flexibility**: Easy to add one-off fields without modifying event classes
|
|
279
|
+
- **Performance**: Disabled debug events are not sent to the logger; guard expensive metadata construction with `if Otto.debug` because method arguments are evaluated first
|
|
280
|
+
- **Consistency**: All timing in microseconds, automatic error handling for timed operations
|
|
281
|
+
- **Maintainability**: One helper file vs multiple event classes/helpers
|