Repository navigation
Commit fae0ad3
authored
feat(server): pluggable request-auth framework (management + runtime) (#204)
## Summary
Pluggable request-auth framework that handles both auth flows the
system needs:
- **Management.** Online check on every request. The default
authorizer authenticates the credential and authorizes the
operation; in production this is `HttpUpstreamAuthProvider`
forwarding to a configurable upstream service.
- **Runtime.** Two-phase exchange-then-verify. A target-bearing call
presents a long-lived credential plus `(target_type, target_id)` to
a token exchange endpoint; the server mints a short-lived HS256 JWT
bound to that target. Subsequent runtime calls verify the JWT
locally, with no upstream round-trip on the hot path.
Both flows route through the same primitives (`Operation` vocabulary
on endpoints, `Principal` returned, `RequestAuthorizer` Protocol
installed); a per-operation registry lets a deployment point
management ops at one provider and runtime ops at another.
Migrates the `/control-bindings` endpoint family onto the framework
and ships the runtime token exchange endpoint. The runtime resolution
path itself (`/evaluation` etc.) is wired in a follow-up; its
provider override (`LocalJwtVerifyProvider`) is already in place when
the runtime secret is configured.
## Module layout
```
server/src/agent_control_server/auth_framework/
__init__.py # public API
core.py # Operation, Principal, RequestAuthorizer, require_operation, registry
config.py # configure_auth_from_env, RuntimeAuthConfig, set_runtime_auth_config
runtime_token.py # HS256 mint / verify helpers, UpstreamGrantExpiredError
providers/
__init__.py
header.py # HeaderAuthProvider + DEFAULT_OPERATION_ACCESS
http_upstream.py # HttpUpstreamAuthProvider (forward + parse grant)
local_jwt.py # LocalJwtVerifyProvider (hot-path JWT verify)
server/src/agent_control_server/endpoints/
auth.py # POST /api/v1/auth/runtime-token-exchange
```
`auth.py` (legacy local credential check) is unchanged;
`HeaderAuthProvider` re-uses `_validate_api_key` from it. Non-binding
routes still go through the legacy router-level gate; their migration
happens in follow-up PRs.
## Operation vocabulary
```python
class Operation(StrEnum):
# Wired on endpoints in this PR.
CONTROL_BINDINGS_READ = "control_bindings.read"
CONTROL_BINDINGS_WRITE = "control_bindings.write"
RUNTIME_TOKEN_EXCHANGE = "runtime.token_exchange"
# Reserved; not yet wired on endpoints.
CONTROLS_READ = "controls.read"
CONTROLS_CREATE = "controls.create"
CONTROLS_UPDATE = "controls.update"
CONTROLS_DELETE = "controls.delete"
RUNTIME_USE = "runtime.use"
```
## Per-operation authorizer registry
`set_authorizer(authorizer, operation=...)` overrides the default for
one operation. Without `operation=`, it becomes the default for every
operation that does not have a specific binding. Used to route
management ops through one provider and `Operation.RUNTIME_USE`
through `LocalJwtVerifyProvider`:
```python
set_authorizer(HttpUpstreamAuthProvider(...)) # default
set_authorizer(LocalJwtVerifyProvider(secret=...), # override
operation=Operation.RUNTIME_USE)
```
`require_operation(op)` consults the override first, falls back to
the default. The local-credential path (no override installed) routes
everything to `HeaderAuthProvider`; the no-auth flow
(`api_key_enabled=False`) is preserved end-to-end.
`require_operation` accepts an optional `context_builder` so the
endpoint can surface request-shaped context (path / query / body
fields) to the authorizer. The body-bearing binding endpoints, the
target-filtered list endpoint, and the runtime token exchange
endpoint all forward `(target_type, target_id)` so an upstream that
resolves the target's owning project has the identifiers it needs to
make a project-level decision.
## Providers (three ship in-tree)
**`HeaderAuthProvider`**: local-credential path, single namespace.
- Maps each `Operation` to one of three access levels (`PUBLIC`,
`AUTHENTICATED`, `ADMIN`); single source of truth in
`DEFAULT_OPERATION_ACCESS`.
- Reuses the existing local API-key + session-cookie credential
check from `auth.py`, so behavior matches the previous
`require_admin_key` path verbatim.
- Returns a normalized `runtime.use` scope only for
`Operation.RUNTIME_TOKEN_EXCHANGE`, so the exchange endpoint can
uniformly require `runtime.use` in `principal.scopes` across every
provider; there is no implicit fallback that could escalate an
upstream-supplied empty scope grant.
- The no-auth flow (`api_key_enabled=False`) is preserved: every
operation succeeds with a non-admin `Principal`. Pinned by a
regression test.
- Always returns `DEFAULT_NAMESPACE_KEY`. The namespace header lookup
branch is preserved but inert until non-binding write endpoints are
threaded.
**`HttpUpstreamAuthProvider`**: generic upstream-delegating provider.
- Forwards caller credentials (`X-API-Key`, `Authorization`,
`Cookie`) on a POST to a configurable URL with
`{operation, context?}`.
- Optional service-to-service token header for upstream trust.
- Parses the upstream response into a `Principal`: `namespace_key`,
`is_admin`, `caller_id`, plus optional grant fields (`target_type`,
`target_id`, `scopes`, `expires_at`) so the runtime token exchange
can mint from the same response.
- Maps `200` to `Principal`; `401` / `403` / `404` to matching error;
`5xx`, network errors, malformed payloads, naive (`tzinfo`-less)
`expires_at`, and partial target grants (only one of `target_type`
/ `target_id`) all fail closed (502/503).
**`LocalJwtVerifyProvider`**: hot-path runtime verifier.
- Reads a Bearer token from `Authorization`, verifies signature
against the runtime secret, checks `domain == "runtime"`, the
issuer, expiry, and that the token's scope covers the requested
`Operation`.
- Returns a `Principal` with the bound `(namespace_key, target_type,
target_id)` so runtime endpoints inherit the namespace and target
binding without re-deriving them.
- When the dependency surfaces `target_type` / `target_id` via
`context_builder`, the provider also enforces that they match the
token's binding; runtime endpoints get the request-target check
for free.
## Runtime token shape
HS256, dedicated secret (`AGENT_CONTROL_RUNTIME_TOKEN_SECRET`),
issuer `agent-control/server`. Claims:
| Claim | Purpose |
|---|---|
| `domain` | Pinned to `runtime`; tokens minted here MUST not be
accepted on management endpoints. |
| `namespace_key` | The namespace the token authorizes within. Required
for mint and verify; preserved end-to-end so a token minted for one
namespace cannot be used to resolve controls in another. |
| `actor_id` | Caller identity surfaced from the upstream grant. |
| `scopes` | Granted runtime capabilities (e.g., `["runtime.use"]`). The
exchange endpoint refuses to mint when `principal.scopes` does not
contain `runtime.use`, including the case where the upstream's grant
explicitly lists an empty scope set. |
| `target_type` / `target_id` | Bind the token to one target. |
| `iat` / `exp` | Bounded lifetime. The local TTL is capped by the
upstream grant's `expires_at` so the local token can never outlive its
grant. |
| `jti` | Random identifier; reserved for future revocation. |
`mint_runtime_token` rejects an `upstream_expires_at` whose
`tzinfo is None` or whose `utcoffset()` is `None` with
`RuntimeTokenError` so a custom authorizer that supplies a naive
datetime surfaces as a typed auth error rather than a raw `TypeError`
deeper in the comparison.
## Runtime token exchange endpoint
```
POST /api/v1/auth/runtime-token-exchange
{ "target_type": "...", "target_id": "..." }
```
- Authenticated and authorized via `Operation.RUNTIME_TOKEN_EXCHANGE`
through the default authorizer (typically
`HttpUpstreamAuthProvider` in production). The authorizer's
`context_builder` forwards the requested target to the upstream so
it can authorize against the right resource.
- Refuses with 503 when `AGENT_CONTROL_RUNTIME_TOKEN_SECRET` is not
configured.
- Mints a local token from `Principal.scopes` /
`Principal.grant_expires_at`, capped by the configured TTL (default
300s).
- When the provider's `Principal` carries a target binding, the
endpoint verifies it matches the requested target before minting.
- An upstream grant whose `expires_at` is already in the past
surfaces as 502 (`UpstreamGrantExpiredError`), distinct from the
503 misconfigured-server path so the public status reflects which
side the operator should investigate.
Response: `{ token, expires_at, target_type, target_id, scopes }`.
## Storage namespace under the framework
The migrated binding endpoints take the storage `namespace_key` from
`get_namespace_key` (the same resolver the rest of the server uses),
not from `principal.namespace_key`. The auth chain still runs through
`require_operation` for authentication and authorization, but the
row's namespace is sourced from the resolver so binding writes and
runtime reads stay in lockstep until auth-derived namespace
resolution lands across `/controls`, `/policies`, `/agents`, and
`/evaluation` together. The principal's namespace is observed (and
used by `LocalJwtVerifyProvider` for its own contract) but is not
used to pick the row's storage namespace at this stage.
## Migrated endpoints
All seven `/api/v1/control-bindings*` endpoints now use
`Depends(require_operation(...))`:
| Method | Path | Operation | Context forwarded |
|---|---|---|---|
| PUT | `/control-bindings` | `control_bindings.write` | body:
`target_type`, `target_id` |
| GET | `/control-bindings` | `control_bindings.read` | query:
`target_type`, `target_id` (when present) |
| GET | `/control-bindings/{binding_id}` | `control_bindings.read` | N/A
(namespace-wide) |
| PATCH | `/control-bindings/{binding_id}` | `control_bindings.write` |
N/A (namespace-wide) |
| DELETE | `/control-bindings/{binding_id}` | `control_bindings.write` |
N/A (namespace-wide) |
| PUT | `/control-bindings/by-key` | `control_bindings.write` | body:
`target_type`, `target_id` |
| POST | `/control-bindings/by-key:delete` | `control_bindings.write` |
body: `target_type`, `target_id` |
The four binding-id-based routes are documented as namespace-wide:
their target identifiers are not available before the binding row is
loaded, and `require_operation` is single-pass. Clients whose
authorization model requires per-target permissions are steered to
the natural-key endpoints and the target-filtered list, all of which
forward the target to the authorizer. Two-phase auth on the by-id
routes is a follow-up.
New: `POST /api/v1/auth/runtime-token-exchange` (operation
`runtime.token_exchange`).
The framework-protected routers (`/control-bindings`, `/auth`) are
mounted with the existing non-validating `get_api_key_from_header`
Security extractor as a router-level dependency. `require_operation`
still owns runtime authentication and authorization; the Security
dependency exists purely so the generated OpenAPI spec advertises
`X-API-Key` on these routes for downstream SDK generation.
## Generated client
The TypeScript wrapper exposes both `auth` and `controlBindings`
getters alongside the existing surface, so consumers using the
public client can call `runtimeTokenExchange` and the binding API
without reaching into the generated internals.
## Env vars
| Var | Default | Purpose |
|---|---|---|
| `AGENT_CONTROL_AUTH_MODE` | `header` | Default authorizer: `header` or
`http_upstream`. |
| `AGENT_CONTROL_AUTH_UPSTREAM_URL` | none | Required when mode is
`http_upstream`. |
| `AGENT_CONTROL_AUTH_UPSTREAM_TIMEOUT_SECONDS` | `5.0` | Per-request
timeout. |
| `AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN` | none | Optional upstream
service token. |
| `AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER` |
`X-Agent-Control-Service-Token` | Header name for the service token. |
| `AGENT_CONTROL_RUNTIME_TOKEN_SECRET` | none | Required to enable
runtime auth + the exchange endpoint. Validated at startup; rejected if
shorter than 32 bytes. |
| `AGENT_CONTROL_RUNTIME_TOKEN_TTL_SECONDS` | `300` | Local token TTL
ceiling (capped further by the upstream grant). Validated at startup. |
`configure_auth_from_env` parses both runtime fields once at startup
into a frozen `RuntimeAuthConfig`. The exchange endpoint and
`LocalJwtVerifyProvider` read the same object, so the mint and verify
sides cannot drift apart on a process. When the runtime secret is
absent, `RUNTIME_USE` falls through to the default authorizer; this
is logged at WARNING so an operator can immediately see what trust
model is in effect. `RUNTIME_USE` is reserved and not wired to
`/evaluation` in this PR, so this fallback does not affect the
runtime hot path yet. The follow-up that wires runtime endpoints
should explicitly choose legacy fallback or fail-closed JWT-only
behavior.
## Out of scope (follow-ups)
- Migrate `/controls` CRUD onto `require_operation` using the
reserved `CONTROLS_*` operations.
- Wire `Operation.RUNTIME_USE` on the runtime resolution path
(`/evaluation`, etc.) and the SDK side of the runtime exchange.
The provider override is already in place when the runtime secret
is configured.
- Migrate `/agents/initAgent` onto `require_operation`. The
`HttpUpstreamAuthProvider`'s `context_builder` should forward the
request's `target_type` / `target_id` to the upstream so the
upstream can authorize against the requested resource.
- Auth-derived `get_namespace_key` so the binding endpoints can use
the principal's namespace for storage along with the rest of the
server.
- Two-phase auth for the four binding-id-based routes
(GET/PATCH/DELETE `/control-bindings/{binding_id}`) so they can
forward target context to the upstream.
- Drop `auth.py`'s `require_admin_key` once every management
endpoint is migrated.
## Stacking
Stacked on **PR #203** (`abhi/data-model-v1`); rebased onto its
current head `8adc328` so the merged effective-controls contract,
namespace-threaded agent endpoints, and savepoint-protected binding
writes are the base this PR builds on. Will rebase onto `main` once
#203 merges.
## Test plan
- [x] 55 framework + endpoint tests covering:
- Default coverage: every `Operation` member has a default access
mapping (regression guard).
- `HeaderAuthProvider`: PUBLIC bypass, AUTHENTICATED + ADMIN paths
route to the legacy validator with the right `require_admin`
flag, no-auth mode passes admin operations, namespace-header
lookup currently inert, unknown operation raises, normalized
`runtime.use` scope returned for `RUNTIME_TOKEN_EXCHANGE`.
- `HttpUpstreamAuthProvider`: 200 happy path with realistic JSON
wire shapes (ISO datetime + JSON array scopes round-trip),
service token forwarding, 401/403/404 mapping, 5xx fail-closed,
network-error fail-closed, strict-grant rejection on wrong-typed
`is_admin` / malformed `scopes` / bad `expires_at` / non-string
target fields, partial target grant rejected, naive `expires_at`
rejected.
- `require_operation` factory: routes through the installed
authorizer, per-operation overrides take precedence, clearing an
override falls back to the default, `get_authorizer` raises
when nothing is set.
- Lifecycle: reconfiguring without the runtime secret drops the
previous `LocalJwtVerifyProvider` override; teardown clears
every authorizer; secret shorter than 32 bytes raises at
startup; invalid TTL raises at startup.
- Runtime token mint / verify: round-trip, wrong-secret rejection,
expiry rejection, TTL capped by upstream grant, management-domain
token refused on runtime verify, missing-namespace rejection,
already-expired upstream grant raises `UpstreamGrantExpiredError`,
naive `upstream_expires_at` raises `RuntimeTokenError`.
- `LocalJwtVerifyProvider`: target-bound `Principal`, namespace
carried from token, missing token returns 401, wrong scope
returns 403, non-Bearer header returns 401, target-context match
enforcement (mismatch on type or id returns 403).
- Exchange endpoint: 503 without secret, mint when configured,
target mismatch rejected (400), missing target rejected (422),
grant-without-runtime-use rejected (no privilege escalation),
explicit empty-scope grant rejected (no fallback escalation),
target context forwarded to authorizer, non-default namespace
propagates into the token, full exchange-then-verify round trip,
already-expired upstream grant surfaces as 502 distinct from the
503 misconfigured-server path.
- [x] Full server suite: 676 passed.
- [x] `make lint` clean.
- [x] `make typecheck` clean.
- [x] `make sdk-ts-generate-check` clean.
- [x] TypeScript SDK regenerated alongside the new endpoint
(`auth-runtime-token-exchange`, request/response models, `Auth`
and `ControlBindings` groups exposed via the public client).1 parent 15ed4fd commit fae0ad3
28 files changed
Lines changed: 3266 additions & 42 deletions
File tree
- sdks/typescript
- overlays
- src
- generated
- funcs
- models
- sdk
- server
- src/agent_control_server
- auth_framework
- providers
- endpoints
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
103 | 108 | | |
104 | 109 | | |
105 | 110 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
52 | 53 | | |
53 | 54 | | |
54 | 55 | | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
55 | 60 | | |
56 | 61 | | |
57 | 62 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
37 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
38 | 41 | | |
39 | 42 | | |
40 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
32 | 40 | | |
33 | 41 | | |
34 | 42 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
32 | 43 | | |
33 | 44 | | |
34 | 45 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
38 | 40 | | |
39 | 41 | | |
40 | 42 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
35 | 40 | | |
36 | 41 | | |
37 | 42 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| 88 | + | |
| 89 | + | |
88 | 90 | | |
89 | 91 | | |
90 | 92 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
0 commit comments