docs(architecture): add ADR-015 privilege model and authority context, resolve OQ-18
ADR-015 locks the call protocol's security model: - internal flag switches authority context to handler identity, not skip ACL - Operations have External/Internal visibility (Internal returns NOT_FOUND from wire, excluded from services/list) - OperationContext carries both identity (caller/principal) and handler_identity (handler/agent) - Scoped composition env bounds reachability (handler can only invoke declared operations) - Three controls together: visibility (wire boundary) + handler identity (authority) + scoped env (reachability) = least privilege Spec updates: - OperationSpec gains Visibility field (External/Internal) - OperationContext gains handler_identity field - AccessControl section: ACL runs against caller identity for external, handler identity for internal - LocalOperationEnv propagates handler_identity - services/list only returns External operations - Adapter-registered operations are Internal by default - OQ-18 resolved, ADR-015 referenced across all call crate specs
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Alknet Architecture
|
# Alknet Architecture
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–014) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), and secret material flow with capability injection (ADR-014). The alknet-core and alknet-call crate specs are in draft.
|
**Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable) and research/reference material. Foundational ADRs (001–015) are in place, including the BiStream type definition (ADR-007), vault integration (ADR-008), ALPN router/endpoint (ADR-010), AuthContext structure (ADR-011), call protocol stream model (ADR-012), Rust as canonical implementation language (ADR-013), secret material flow with capability injection (ADR-014), and privilege model with authority context (ADR-015). The alknet-core and alknet-call crate specs are in draft.
|
||||||
|
|
||||||
**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability) and OQ-15 (call protocol client and adapter contract) will be resolved during implementation.
|
**Next step**: Review alknet-call spec documents, then begin implementation. OQ-11 (handler-level auth resolution observability), OQ-15 (call protocol client and adapter contract), and OQ-17 (abort cascade) will be resolved during or before implementation.
|
||||||
|
|
||||||
## Architecture Documents
|
## Architecture Documents
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ last_updated: 2026-06-19
|
|||||||
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted |
|
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted |
|
||||||
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted |
|
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted |
|
||||||
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Accepted |
|
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Accepted |
|
||||||
|
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Accepted |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
|||||||
- **OQ-06**: ALPN per connection, not per stream (ADR-006)
|
- **OQ-06**: ALPN per connection, not per stream (ADR-006)
|
||||||
- **OQ-08**: Vault integration — CLI-embedded, assembly-layer only (ADR-008, ADR-014)
|
- **OQ-08**: Vault integration — CLI-embedded, assembly-layer only (ADR-008, ADR-014)
|
||||||
- **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014)
|
- **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014)
|
||||||
|
- **OQ-18**: Privilege model — `internal` = authority switch, External/Internal visibility, handler identity + scoped env (ADR-015)
|
||||||
|
|
||||||
**Resolved two-way doors:**
|
**Resolved two-way doors:**
|
||||||
- **OQ-04**: Dynamic handler registration — static at startup (ADR-010)
|
- **OQ-04**: Dynamic handler registration — static at startup (ADR-010)
|
||||||
@@ -69,9 +71,8 @@ See [open-questions.md](open-questions.md) for the full tracker.
|
|||||||
- **OQ-11**: Handler-level auth resolution observability — decide during implementation
|
- **OQ-11**: Handler-level auth resolution observability — decide during implementation
|
||||||
|
|
||||||
**Open one-way doors (need ADR before implementation):**
|
**Open one-way doors (need ADR before implementation):**
|
||||||
- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
- **OQ-15**: Call protocol client and adapter contract — alknet-call needs both the server (CallAdapter) and client (call invocation over QUIC), plus the adapter contract traits (from_*, to_*) that enable composition. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default.
|
||||||
- **OQ-17**: Abort cascade semantics — `call.aborted` cascades to descendants. Default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
- **OQ-17**: Abort cascade semantics — `call.aborted` cascades to descendants. Default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||||
- **OQ-18**: Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Protocol-level concern — every consumer inherits this model.
|
|
||||||
- **OQ-19**: Session-scoped operation registries — agent-written operations in a quickjs sandbox, overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; the one-way door is not closing the trait-based composition point. Promotion from session to core requires curation review.
|
- **OQ-19**: Session-scoped operation registries — agent-written operations in a quickjs sandbox, overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; the one-way door is not closing the trait-based composition point. Promotion from session to core requires curation review.
|
||||||
|
|
||||||
**Deferred (not active):**
|
**Deferred (not active):**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# alknet-call
|
# alknet-call
|
||||||
@@ -29,6 +29,7 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
|||||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
|
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
|
||||||
| [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
|
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
@@ -37,10 +38,9 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
|||||||
| OQ-07 | Call protocol scope within a connection | resolved (ADR-012) | Stream model, multiplexing, scope |
|
| OQ-07 | Call protocol scope within a connection | resolved (ADR-012) | Stream model, multiplexing, scope |
|
||||||
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
|
||||||
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
|
||||||
| OQ-15 | Call protocol client and adapter contract | open | ADR-014 constrains adapters: credential sources, not static tokens |
|
| OQ-15 | Call protocol client and adapter contract | open | ADR-014 constrains adapters: credential sources, not static tokens. ADR-015: adapter ops are Internal by default |
|
||||||
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now |
|
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now |
|
||||||
| OQ-17 | Abort cascade semantics | open | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in. One-way door on event schema |
|
| OQ-17 | Abort cascade semantics | open | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in. One-way door on event schema |
|
||||||
| OQ-18 | Privilege model and authority context | open | `internal` flag switches authority to handler identity, not blanket ACL skip. External/Internal operation visibility. Scoped composition env + handler identity. Protocol-level concern |
|
|
||||||
| OQ-19 | Session-scoped operation registries | open | Agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point |
|
| OQ-19 | Session-scoped operation registries | open | Agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
@@ -53,4 +53,4 @@ Structured RPC over QUIC: operations, request/response, streaming subscriptions,
|
|||||||
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
|
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
|
||||||
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
|
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
|
||||||
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See OQ-17.
|
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See OQ-17.
|
||||||
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's identity, not the caller's and not as a blanket skip. See OQ-18.
|
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's identity, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Call Protocol
|
# Call Protocol
|
||||||
@@ -301,6 +301,7 @@ The one-way door is the protocol event schema: `call.aborted` must carry cascade
|
|||||||
| ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
| ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
|
||||||
| Vault integration point | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration point | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
||||||
| Secret material flow | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
| Secret material flow | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
|
||||||
|
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -308,10 +309,9 @@ See [open-questions.md](../../open-questions.md) for full details.
|
|||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default.
|
||||||
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||||
- **OQ-18** (open): Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Protocol-level concern — every consumer inherits this model.
|
|
||||||
- **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes.
|
- **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-06-19
|
last_updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Operation Registry
|
# Operation Registry
|
||||||
@@ -34,6 +34,7 @@ pub struct OperationSpec {
|
|||||||
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
|
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
|
||||||
pub namespace: String, // e.g., "fs", "agent"
|
pub namespace: String, // e.g., "fs", "agent"
|
||||||
pub op_type: OperationType, // Query, Mutation, Subscription
|
pub op_type: OperationType, // Query, Mutation, Subscription
|
||||||
|
pub visibility: Visibility, // External (wire-callable) or Internal (composition-only)
|
||||||
pub input_schema: Value, // JSON Schema for input
|
pub input_schema: Value, // JSON Schema for input
|
||||||
pub output_schema: Value, // JSON Schema for output
|
pub output_schema: Value, // JSON Schema for output
|
||||||
pub access_control: AccessControl,
|
pub access_control: AccessControl,
|
||||||
@@ -44,12 +45,19 @@ pub enum OperationType {
|
|||||||
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
|
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
|
||||||
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
|
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum Visibility {
|
||||||
|
External, // Callable from the wire (call.requested from a client)
|
||||||
|
Internal, // Composition-only (env.invoke from a handler)
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`).
|
Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`).
|
||||||
|
|
||||||
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
|
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
|
||||||
|
|
||||||
|
Visibility (ADR-015) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. `from_openapi` and `from_jsonschema` adapters register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`.
|
||||||
|
|
||||||
### AccessControl
|
### AccessControl
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -63,13 +71,14 @@ pub struct AccessControl {
|
|||||||
|
|
||||||
When a `call.requested` event arrives:
|
When a `call.requested` event arrives:
|
||||||
1. The `CallAdapter` resolves the caller's `Identity` from `AuthContext` (and possibly an `AuthToken` in the payload)
|
1. The `CallAdapter` resolves the caller's `Identity` from `AuthContext` (and possibly an `AuthToken` in the payload)
|
||||||
2. The registry checks `access_control.check(identity)` before invoking the handler
|
2. The registry checks operation **visibility** — if the operation is `Internal`, returns `call.error` with code `NOT_FOUND` (does not leak existence)
|
||||||
3. If access is denied, the adapter returns `call.error` with code `FORBIDDEN`
|
3. The registry checks `access_control.check(identity)` — for external calls (`internal: false`), ACL runs against the **caller's identity**; for internal calls (`internal: true`), ACL runs against the **handler's identity** (ADR-015)
|
||||||
4. If the identity is `None` and the operation has restrictions, the adapter returns `call.error` with code `FORBIDDEN` and message `"authentication required"`
|
4. If access is denied, the adapter returns `call.error` with code `FORBIDDEN`
|
||||||
|
5. If the relevant identity is `None` and the operation has restrictions, the adapter returns `call.error` with code `FORBIDDEN` and message `"authentication required"`
|
||||||
|
|
||||||
Operations with empty `AccessControl` (no required scopes, no resource checks) are accessible to all callers, including unauthenticated ones.
|
Operations with empty `AccessControl` (no required scopes, no resource checks) are accessible to all callers, including unauthenticated ones.
|
||||||
|
|
||||||
**Internal calls and authority context**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `internal: true`, meaning it originated from composition (not from a wire request). The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This prevents privilege escalation through composition — a handler can only compose operations its own identity is authorized for. See OQ-18 for the full privilege model.
|
**Internal calls and authority context**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `internal: true`, meaning it originated from composition (not from a wire request). The `internal` flag switches the authority context: the ACL check runs against the composing handler's `handler_identity` (set at registration), not the caller's identity and not as a blanket skip. This prevents privilege escalation through composition — a handler can only compose operations its own identity is authorized for. See ADR-015.
|
||||||
|
|
||||||
### Handler
|
### Handler
|
||||||
|
|
||||||
@@ -91,7 +100,8 @@ And returns a `ResponseEnvelope` containing the result or an error.
|
|||||||
pub struct OperationContext {
|
pub struct OperationContext {
|
||||||
pub request_id: String,
|
pub request_id: String,
|
||||||
pub parent_request_id: Option<String>,
|
pub parent_request_id: Option<String>,
|
||||||
pub identity: Option<Identity>,
|
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
|
||||||
|
pub handler_identity: Option<Identity>, // Handler's identity (composition authority — who am I acting as)
|
||||||
pub capabilities: Capabilities,
|
pub capabilities: Capabilities,
|
||||||
pub metadata: HashMap<String, Value>,
|
pub metadata: HashMap<String, Value>,
|
||||||
pub env: OperationEnv,
|
pub env: OperationEnv,
|
||||||
@@ -100,14 +110,15 @@ pub struct OperationContext {
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `request_id`: Correlates with the `call.requested` event's `id` field
|
- `request_id`: Correlates with the `call.requested` event's `id` field
|
||||||
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`)
|
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`). Records the agency chain — the call tree is the principal→agent chain (ADR-015)
|
||||||
- `identity`: The authenticated identity making the call (from `IdentityProvider`) — inbound auth (who is calling me)
|
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
|
||||||
|
- `handler_identity`: The identity of the handler processing this call. Set at registration by the assembly layer. For internal calls (`internal: true`), the ACL check runs against this identity (ADR-015)
|
||||||
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
|
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
|
||||||
- `metadata`: Additional context (connection info, tracing IDs). **Must not hold secret material** — see ADR-014
|
- `metadata`: Additional context (connection info, tracing IDs). **Must not hold secret material** — see ADR-014
|
||||||
- `env`: The operation environment for composing calls to other operations
|
- `env`: The operation environment for composing calls to other operations. Scoped — the handler can only invoke a declared set of operations (ADR-015)
|
||||||
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: the ACL check runs against the composing handler's identity, not the caller's and not as a blanket skip. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See OQ-18.
|
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: ACL runs against `handler_identity`, not `identity`. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See ADR-015.
|
||||||
|
|
||||||
`identity` and `capabilities` are orthogonal: identity is inbound (resolved per-request from the caller's credentials), capabilities are outbound (provisioned by the assembly layer from the vault). See ADR-014 for the full rationale. The `internal` flag governs which authority applies to composition — see OQ-18 for the privilege model.
|
`identity` and `capabilities` are orthogonal: identity is inbound (who is calling me), capabilities are outbound (what credentials I can use). `identity` and `handler_identity` are the principal/agent pair: `identity` is the principal (who delegated), `handler_identity` is the agent (who is acting). See ADR-014 for capabilities and ADR-015 for the privilege model.
|
||||||
|
|
||||||
### OperationRegistry
|
### OperationRegistry
|
||||||
|
|
||||||
@@ -163,7 +174,8 @@ impl OperationEnv for LocalOperationEnv {
|
|||||||
let context = OperationContext {
|
let context = OperationContext {
|
||||||
request_id: format!("env-{name}"),
|
request_id: format!("env-{name}"),
|
||||||
parent_request_id: Some(parent.request_id.clone()),
|
parent_request_id: Some(parent.request_id.clone()),
|
||||||
identity: parent.identity.clone(), // Inherit caller's identity
|
identity: parent.handler_identity.clone(), // Parent's handler identity becomes the caller
|
||||||
|
handler_identity: parent.handler_identity.clone(), // Inherit handler authority for ACL
|
||||||
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
|
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
|
||||||
metadata: parent.metadata.clone(), // Inherit caller's metadata
|
metadata: parent.metadata.clone(), // Inherit caller's metadata
|
||||||
env: self.clone(),
|
env: self.clone(),
|
||||||
@@ -187,6 +199,8 @@ Two built-in operations expose what the node offers:
|
|||||||
|
|
||||||
These are read-only — no admin operations are exposed through the call protocol itself.
|
These are read-only — no admin operations are exposed through the call protocol itself.
|
||||||
|
|
||||||
|
`services/list` only returns `External` operations to remote callers. `Internal` operations are not part of the wire-facing API surface — they're implementation details of composition. A remote client cannot enumerate the internal call tree. See ADR-015.
|
||||||
|
|
||||||
`services/list` returns:
|
`services/list` returns:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -274,7 +288,9 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
|
|
||||||
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
|
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
|
||||||
|
|
||||||
**Adapters take credential sources.** The `from_openapi` and `from_jsonschema` adapter patterns (see OQ-15, constrained by ADR-014) register HTTP-backed operations. The credential the HTTP service needs (bearer token, API key) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into HTTP-backed operations, including LLM providers that expose OpenAPI-compatible endpoints.
|
**Adapters take credential sources.** The `from_openapi` and `from_jsonschema` adapter patterns (see OQ-15, constrained by ADR-014) register HTTP-backed operations. The credential the HTTP service needs (bearer token, API key) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into HTTP-backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-015) — they're composition material, not directly callable from the wire.
|
||||||
|
|
||||||
|
**Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration by the assembly layer. This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the handler identity is the authority control. Both are needed for least privilege. See ADR-015.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
@@ -282,7 +298,9 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
|
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
|
||||||
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
|
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
|
||||||
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
|
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
|
||||||
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (handler identity for ACL), it does not skip ACL — see OQ-18.
|
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (handler identity for ACL), it does not skip ACL — see ADR-015.
|
||||||
|
- **Operations have External/Internal visibility.** `Internal` operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The assembly layer declares visibility at registration. See ADR-015.
|
||||||
|
- **The composition env is scoped.** A handler can only invoke operations declared in its scoped env. This bounds parameterized-dispatch attack surface. See ADR-015.
|
||||||
- **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire.
|
- **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire.
|
||||||
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014.
|
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014.
|
||||||
|
|
||||||
@@ -295,6 +313,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
|||||||
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Registry is immutable after construction |
|
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Registry is immutable after construction |
|
||||||
| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
|
||||||
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
|
||||||
|
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
@@ -302,10 +321,9 @@ See [open-questions.md](../../open-questions.md) for full details.
|
|||||||
|
|
||||||
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
|
||||||
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
|
||||||
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens.
|
- **OQ-15** (open): Call protocol client and adapter contract. ADR-014 constrains the adapter contract: adapters take credential sources from the assembly layer, not static tokens. ADR-015 constrains: adapter-registered operations are `Internal` by default.
|
||||||
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
|
||||||
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
- **OQ-17** (open): Abort cascade semantics — `call.aborted` cascades to descendants, default `abort-dependents`, `continue-running` opt-in. One-way door on the event schema; mechanism is a two-way door.
|
||||||
- **OQ-18** (open): Privilege model and authority context — `internal` flag switches authority to handler identity, not blanket ACL skip. Operations have External/Internal visibility. Scoped composition env + handler identity. Protocol-level concern — every consumer inherits this model.
|
|
||||||
- **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point.
|
- **OQ-19** (open): Session-scoped operation registries — agent-written operations overlaid on the global registry via `OperationEnv` trait layering. Protocol doesn't need changes; one-way door is not closing the trait-based composition point.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# ADR-015: Privilege Model and Authority Context
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The call protocol allows handlers to compose other operations through
|
||||||
|
`OperationEnv::invoke()`. This creates a call tree: a parent request spawns
|
||||||
|
children, which may spawn their own children. The `parent_request_id` field
|
||||||
|
records this tree.
|
||||||
|
|
||||||
|
The previous design had a `trusted: bool` flag on `OperationContext`. When a
|
||||||
|
handler invoked another operation through `OperationEnv`, the nested call was
|
||||||
|
marked `trusted: true` and **all ACL checks were skipped**. The intent was to
|
||||||
|
avoid double-checking: if `/agent/chat` is allowed and it internally calls
|
||||||
|
`/auth/verify`, the auth check is "trusted" because the caller already passed
|
||||||
|
ACL on `/agent/chat`.
|
||||||
|
|
||||||
|
This is a privilege escalation vector. Two concrete attacks:
|
||||||
|
|
||||||
|
**Buggy handler**: a handler accidentally calls an operation it shouldn't. With
|
||||||
|
`trusted: true`, ACL is skipped entirely. A handler with `read` scope that
|
||||||
|
accidentally calls an operation requiring `admin` succeeds — the caller's `read`
|
||||||
|
scope effectively triggered an `admin` operation.
|
||||||
|
|
||||||
|
**Parameterized dispatch**: a handler takes caller input that determines which
|
||||||
|
internal operation to call. This is the core agent use case — an LLM picks which
|
||||||
|
tool to invoke based on the user's prompt. With `trusted: true`, the LLM (and
|
||||||
|
therefore the user) can invoke any registered operation without ACL checks,
|
||||||
|
regardless of the caller's scopes. A caller with `chat` scope can invoke
|
||||||
|
operations requiring `admin` by choosing the right tool name.
|
||||||
|
|
||||||
|
The call protocol is a general-purpose cross-boundary RPC mechanism. Every
|
||||||
|
consumer — NAPI adapter, Python adapter, agent service, future services —
|
||||||
|
inherits whatever privilege model the protocol defines. The privilege boundary
|
||||||
|
between external and internal calls, and the authority context switch for
|
||||||
|
composition, are core protocol semantics. This is not a feature of any single
|
||||||
|
consumer; it is the protocol's security model.
|
||||||
|
|
||||||
|
The agent service is a useful test case because it exercises every edge case
|
||||||
|
(parameterized dispatch, deep composition, dynamic operations, role-based
|
||||||
|
escalation), but the decision belongs to the call protocol.
|
||||||
|
|
||||||
|
## Mental Models
|
||||||
|
|
||||||
|
Two analogies clarify the model:
|
||||||
|
|
||||||
|
**Kernel/user mode**: external operations are syscalls — curated entry points
|
||||||
|
where an unprivileged caller can enter the kernel. Internal operations are
|
||||||
|
kernel functions — callable only from composition, not from userspace. The
|
||||||
|
`internal` flag means "this call is in kernel mode." Kernel mode has access
|
||||||
|
controls — it runs under a different principal, not with no principal.
|
||||||
|
|
||||||
|
**Domain/integration events**: external operations are integration events —
|
||||||
|
they cross a boundary and are visible to external systems. Internal operations
|
||||||
|
are domain events — they stay within the bounded context. `services/list` is
|
||||||
|
the integration contract; it only exposes integration events.
|
||||||
|
|
||||||
|
**Principal/agent (legal contracting)**: the caller is the principal; the
|
||||||
|
handler is the agent. The principal delegates scoped authority to the agent.
|
||||||
|
The agent acts under its own identity (for attribution) but with the principal's
|
||||||
|
delegated authority (for scope). Liabilities flow upstream (traceable through
|
||||||
|
`parent_request_id`); privileges flow downstream (the agent gets a subset of the
|
||||||
|
principal's authority). Role-based escalation: a lower-privileged role can
|
||||||
|
escalate through a chain of command (agent requests promotion, architect
|
||||||
|
performs it), not through direct authority.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The `internal` flag switches authority context, not skips ACL
|
||||||
|
|
||||||
|
The `internal` flag on `OperationContext` marks calls that originated from
|
||||||
|
composition (a handler calling another operation via `OperationEnv`), as opposed
|
||||||
|
to external calls that arrived as `call.requested` from a wire client.
|
||||||
|
|
||||||
|
When `internal: true`:
|
||||||
|
- The ACL check runs against the **handler's identity** (set at registration by
|
||||||
|
the assembly layer), not the caller's identity and not as a blanket skip.
|
||||||
|
- The handler's identity has scopes scoped to its composition needs (least
|
||||||
|
privilege), not blanket root and not the caller's scopes.
|
||||||
|
|
||||||
|
When `internal: false` (external call from the wire):
|
||||||
|
- The ACL check runs against the **caller's identity** (from `AuthContext`,
|
||||||
|
resolved per-request).
|
||||||
|
|
||||||
|
The `internal` flag is set by `OperationEnv`, not by callers. A handler cannot
|
||||||
|
mark its own call as internal. The field uses module-private construction; only
|
||||||
|
`pub fn is_internal(&self) -> bool` is exposed for reads.
|
||||||
|
|
||||||
|
### 2. Operations have External/Internal visibility
|
||||||
|
|
||||||
|
`OperationSpec` has a `visibility: Visibility` field:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Visibility {
|
||||||
|
External, // Callable from the wire (call.requested from a client)
|
||||||
|
Internal, // Composition-only (env.invoke from a handler)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The assembly layer declares visibility when registering operations.
|
||||||
|
|
||||||
|
When a `call.requested` arrives from a wire client:
|
||||||
|
- An `Internal` operation returns `call.error` with code `NOT_FOUND` (not
|
||||||
|
`FORBIDDEN`). This does not leak that the operation exists.
|
||||||
|
- An `External` operation proceeds to ACL checking.
|
||||||
|
|
||||||
|
`services/list` only returns `External` operations to remote callers. Internal
|
||||||
|
operations are not part of the wire-facing API surface. A remote client cannot
|
||||||
|
enumerate the internal call tree.
|
||||||
|
|
||||||
|
### 3. Handler identity is carried on OperationContext
|
||||||
|
|
||||||
|
`OperationContext` carries both the caller's identity (who invoked me) and the
|
||||||
|
handler's identity (who am I acting as):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct OperationContext {
|
||||||
|
pub request_id: String,
|
||||||
|
pub parent_request_id: Option<String>,
|
||||||
|
pub identity: Option<Identity>, // Caller's identity (inbound)
|
||||||
|
pub handler_identity: Option<Identity>, // Handler's identity (composition authority)
|
||||||
|
pub capabilities: Capabilities,
|
||||||
|
pub metadata: HashMap<String, Value>,
|
||||||
|
pub env: OperationEnv,
|
||||||
|
pub internal: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `identity`: the authenticated caller (from `AuthContext`). For external calls,
|
||||||
|
this is who sent the `call.requested`. For internal calls, this is the
|
||||||
|
*parent handler's* identity (propagated through `OperationEnv::invoke()`).
|
||||||
|
- `handler_identity`: the identity of the handler processing this call. Set at
|
||||||
|
registration by the assembly layer. For external calls, this is the handler's
|
||||||
|
own identity. For internal calls, the ACL check runs against this identity.
|
||||||
|
|
||||||
|
The distinction is the principal/agent model: `identity` is the principal (who
|
||||||
|
delegated), `handler_identity` is the agent (who is acting). Attribution traces
|
||||||
|
through both — any action can be attributed to the handler that performed it and
|
||||||
|
the caller that initiated the chain.
|
||||||
|
|
||||||
|
### 4. Scoped composition env
|
||||||
|
|
||||||
|
The `OperationEnv` given to a handler is scoped — it can only invoke a declared
|
||||||
|
set of operations. This bounds the parameterized-dispatch attack surface: a
|
||||||
|
caller (or an LLM) picking which operation to invoke picks from the declared
|
||||||
|
set, not from the entire registry.
|
||||||
|
|
||||||
|
Scoping happens at two levels:
|
||||||
|
|
||||||
|
**Static scoping at registration**: the assembly layer declares which operations
|
||||||
|
a handler may compose. The `OperationEnv` given to that handler is pre-filtered
|
||||||
|
— `invoke("fs", "readFile", ...)` works, `invoke("admin", "deleteUser", ...)`
|
||||||
|
returns `NOT_FOUND`. This is the reachability control.
|
||||||
|
|
||||||
|
**Dynamic scoping at sandbox creation**: when a handler spawns a sandbox
|
||||||
|
(quickjs), it passes a *further scoped* env to the sandbox — a subset of what
|
||||||
|
the handler itself can reach. The handler might have `fs:read` and `bash:exec`,
|
||||||
|
but it only gives the sandbox `fs:read` (not `bash:exec`), because the sandbox
|
||||||
|
runs untrusted LLM-generated code. This is the "privileges flow downstream"
|
||||||
|
principle: the principal delegates a subset.
|
||||||
|
|
||||||
|
The specific API for declaring the scoped operation set (allowed-operations
|
||||||
|
list, allowed-namespaces, or a trait-based filter) is a two-way door for
|
||||||
|
implementation. The TypeScript `@alkdev/operations` `buildEnv()` used an
|
||||||
|
`allowedNamespaces` filter; the Rust implementation may be finer-grained
|
||||||
|
(operation-level, not just namespace-level) to be safe.
|
||||||
|
|
||||||
|
### 5. The three controls together
|
||||||
|
|
||||||
|
The three controls are independent and all are needed:
|
||||||
|
|
||||||
|
| Control | What it gates | Without it |
|
||||||
|
|---------|--------------|-----------|
|
||||||
|
| Operation visibility | Whether an operation is callable from the wire | Internal operations exposed to external callers |
|
||||||
|
| Handler identity | What authority composition runs under | ACL skipped or caller's scopes propagated (escalation) |
|
||||||
|
| Scoped composition env | What operations a handler can reach | Handler can call anything in the registry |
|
||||||
|
|
||||||
|
- Visibility alone: internal operations are hidden from the wire, but
|
||||||
|
composition skips ACL (escalation through buggy handler).
|
||||||
|
- Handler identity alone: ACL checks against handler scopes, but the handler can
|
||||||
|
reach any operation (parameterized dispatch unbounded).
|
||||||
|
- Scoped env alone: handler can only reach declared operations, but ACL is
|
||||||
|
skipped (if a declared operation requires a scope the handler doesn't have, it
|
||||||
|
still runs).
|
||||||
|
|
||||||
|
All three together: the handler can only reach declared operations (scoped env),
|
||||||
|
those operations are ACL-checked against the handler's scoped identity (handler
|
||||||
|
identity), and internal operations are never exposed to the wire (visibility).
|
||||||
|
Principle of least privilege.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- No privilege escalation through composition. A handler can only compose
|
||||||
|
operations its own identity is authorized for, and only from its declared
|
||||||
|
scope.
|
||||||
|
- Parameterized dispatch is safe. The agent/LLM tool selection case is bounded
|
||||||
|
by the scoped env — the LLM picks from the declared tool set, not from the
|
||||||
|
entire registry. The ACL checks against the handler's identity, not the
|
||||||
|
caller's.
|
||||||
|
- Buggy handlers can't accidentally escalate. A handler that tries to call an
|
||||||
|
operation outside its scoped env gets `NOT_FOUND`; one that calls an operation
|
||||||
|
its identity lacks scopes for gets `FORBIDDEN`.
|
||||||
|
- Attribution is complete. Every call carries both the caller's identity (who
|
||||||
|
initiated the chain) and the handler's identity (who is acting). The
|
||||||
|
`parent_request_id` chain traces the full agency chain. This supports the
|
||||||
|
gitea-per-agent pattern where each agent (human or LLM) has its own account.
|
||||||
|
- Session-scoped operations (OQ-19) are safe by construction. They're always
|
||||||
|
`Internal`, run under the handler's identity, through the scoped env, in a
|
||||||
|
locked-down sandbox. The self-improving workflow (agents writing tools) is
|
||||||
|
bounded.
|
||||||
|
- Role-based escalation is explicit. An agent requesting promotion (session →
|
||||||
|
core) is a lower-privileged role asking a higher-privileged role (architect
|
||||||
|
with `promote` scope) to perform an action. The escalation goes through the
|
||||||
|
chain of command, not through direct authority.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- `OperationContext` has two identity fields (`identity` and
|
||||||
|
`handler_identity`), which is more complex than a single identity. This is
|
||||||
|
necessary — the principal/agent distinction is real and both are needed for
|
||||||
|
attribution and ACL.
|
||||||
|
- The assembly layer has more responsibility: it must declare each handler's
|
||||||
|
identity (scopes), its scoped composition env (which operations it may
|
||||||
|
compose), and operation visibility. This is expected — the assembly layer
|
||||||
|
assembles everything (ADR-008), and forcing explicit declaration of privilege
|
||||||
|
is a feature, not a bug.
|
||||||
|
- Adding a new composition to a handler requires updating the assembly layer
|
||||||
|
(declare the new operation in the scoped env), not just the handler code.
|
||||||
|
This prevents accidental composition of unauthorized operations.
|
||||||
|
- The scoped env API is not fully specified here. The one-way constraint
|
||||||
|
(scoped env exists, is declared at registration, can be further scoped at
|
||||||
|
runtime) is fixed; the concrete API is a two-way door for implementation.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
1. **Internal calls should run under a different authority than external calls,
|
||||||
|
not skip ACL entirely.** If internal calls should skip ACL (the old `trusted`
|
||||||
|
model), this entire ADR is wrong. The assumption is that the escalation
|
||||||
|
vectors (buggy handler, parameterized dispatch) are real and must be
|
||||||
|
prevented.
|
||||||
|
|
||||||
|
2. **Handler identity is set at registration by the assembly layer.** The
|
||||||
|
assembly layer is the trust boundary (ADR-008, ADR-014). If the assembly
|
||||||
|
layer is compromised, all handler identities are compromised. This is the
|
||||||
|
same trust boundary as capabilities.
|
||||||
|
|
||||||
|
3. **The scoped env is declared at registration (static) and can be further
|
||||||
|
scoped at runtime (dynamic, for sandbox creation).** The static scoping is
|
||||||
|
the reachability control; the dynamic scoping is the sandbox boundary. If a
|
||||||
|
use case requires fully dynamic scoping (handler discovers at call time what
|
||||||
|
it can compose), the model needs extension — but the assumption is that
|
||||||
|
composition reachability is knowable at registration time.
|
||||||
|
|
||||||
|
4. **`services/list` hides internal operations.** If internal operations should
|
||||||
|
be discoverable by remote callers (e.g., for debugging), the visibility model
|
||||||
|
needs a third state. The assumption is that internal operations are
|
||||||
|
implementation details, not part of the external API surface.
|
||||||
|
|
||||||
|
5. **Internal operations return `NOT_FOUND`, not `FORBIDDEN`.** This prevents
|
||||||
|
existence leakage. If a use case requires distinguishing "you can't call
|
||||||
|
this" from "this doesn't exist" (e.g., for debugging), the error model needs
|
||||||
|
refinement. The assumption is that not leaking internal operation existence
|
||||||
|
is more important than debuggability from the wire.
|
||||||
|
|
||||||
|
6. **The handler identity is a full `Identity` (with scopes), not a special
|
||||||
|
principal type.** This reuses the existing `Identity` type and `IdentityProvider`
|
||||||
|
infrastructure (ADR-004). If handler identities need different resolution
|
||||||
|
semantics (e.g., not resolvable through `IdentityProvider`), a separate type
|
||||||
|
may be needed. The assumption is that the existing identity infrastructure
|
||||||
|
suffices.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-004: Auth as shared core (`IdentityProvider`, `Identity`)
|
||||||
|
- ADR-008: Vault integration (assembly layer is the trust boundary)
|
||||||
|
- ADR-014: Secret material flow and capability injection (capabilities are
|
||||||
|
orthogonal — both are set at registration by the assembly layer)
|
||||||
|
- OQ-15: Call protocol client and adapter contract (adapters produce scoped envs)
|
||||||
|
- OQ-17: Abort cascade (the call tree is the agency chain — `parent_request_id`
|
||||||
|
traces principal → agent)
|
||||||
|
- OQ-19: Session-scoped registries (session operations are always `Internal`)
|
||||||
|
- [operation-registry.md](../crates/call/operation-registry.md)
|
||||||
|
- [call-protocol.md](../crates/call/call-protocol.md)
|
||||||
|
- TypeScript `@alkdev/operations` `buildEnv()` with `allowedNamespaces` — prior
|
||||||
|
art for scoped composition env
|
||||||
|
- POC at `/workspace/toolEnv` — demonstrated the sandbox-to-registry bridge with
|
||||||
|
the full-registry exposure gap
|
||||||
@@ -201,28 +201,11 @@ These questions are acknowledged but not active. They will be promoted to open w
|
|||||||
### OQ-18: Privilege Model and Authority Context
|
### OQ-18: Privilege Model and Authority Context
|
||||||
|
|
||||||
- **Origin**: [operation-registry.md](crates/call/operation-registry.md)
|
- **Origin**: [operation-registry.md](crates/call/operation-registry.md)
|
||||||
- **Status**: open
|
- **Status**: resolved
|
||||||
- **Door type**: One-way (ACL model), two-way (specific APIs)
|
- **Door type**: One-way (ACL model), two-way (specific APIs)
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
- **Resolution**: The `internal` flag on `OperationContext` marks calls that originated from composition (a handler calling another operation via `OperationEnv`), as opposed to external calls that arrived as `call.requested` from a wire client. The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This replaces the previous `trusted` flag, which skipped ACL entirely — a privilege escalation vector.
|
- **Resolution**: The `internal` flag on `OperationContext` marks calls that originated from composition (a handler calling another operation via `OperationEnv`), as opposed to external calls that arrived as `call.requested` from a wire client. The `internal` flag switches the authority context: the ACL check runs against the composing handler's identity (set at registration), not the caller's identity and not as a blanket skip. This replaces the previous `trusted` flag, which skipped ACL entirely — a privilege escalation vector. Operations have External/Internal visibility. Internal operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The composition env is scoped — a handler can only invoke a declared set of operations. Handler identity is carried on `OperationContext` alongside caller identity (the principal/agent pair). See ADR-015.
|
||||||
|
- **Cross-references**: ADR-014, ADR-015, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)
|
||||||
The model has two analogies: kernel/user mode (external operations are syscalls — curated entry points; internal operations are kernel functions — composition-only), and domain/integration events (external operations are integration events — cross-boundary; internal operations are domain events — within the bounded context).
|
|
||||||
|
|
||||||
Three controls prevent privilege escalation through composition:
|
|
||||||
1. **Operation visibility**: `OperationSpec` has a `Visibility` field (`External` — callable from the wire, or `Internal` — composition-only). When a `call.requested` arrives from a client, the registry checks visibility: an `Internal` operation returns `NOT_FOUND` (not `FORBIDDEN` — don't leak that it exists). `services/list` only returns `External` operations to remote callers.
|
|
||||||
2. **Handler identity**: Each handler has its own `Identity` with scopes scoped to its composition needs, set at registration by the assembly layer. Internal calls use the handler's identity for ACL, not the caller's. The handler's identity has only the scopes it needs (least privilege), not blanket root and not the caller's scopes.
|
|
||||||
3. **Scoped composition env**: The `OperationEnv` given to a handler can only invoke a declared set of operations. This bounds the parameterized-dispatch attack surface — a caller (or an LLM) picking which operation to invoke picks from the declared set, not from the entire registry.
|
|
||||||
|
|
||||||
Two escalation vectors that this model addresses:
|
|
||||||
- **Buggy handler**: a handler accidentally calls an operation it shouldn't. With handler identity + scoped env, the call either isn't reachable (scoped env) or fails ACL (handler identity lacks the scope). Under the old `trusted` model, ACL was skipped entirely.
|
|
||||||
- **Parameterized dispatch**: a handler takes caller input that determines which internal operation to call. With scoped env, the handler can only reach declared operations. With handler identity, the ACL checks against the handler's scopes, not the caller's. The caller's scopes only gate entry to the external operation; they don't propagate into the composition.
|
|
||||||
|
|
||||||
The one-way door is the ACL model (internal = authority context switch, not skip; visibility = External/Internal; handler identity + scoped env). The specific APIs — how handler identity is declared, how the scoped env trait works, how visibility interacts with `services/list` filtering — are two-way doors.
|
|
||||||
|
|
||||||
This is a protocol-level concern. The call protocol is a general-purpose cross-boundary RPC mechanism — every consumer (NAPI adapter, Python adapter, agent service, future services speaking the EventEnvelope wire format) inherits whatever privilege model is locked in. The privilege boundary between external and internal calls, and the authority context switch for composition, are core protocol semantics, not features of any single consumer. The agent use case is a useful test case for thinking through the edge cases (parameterized dispatch via LLM tool selection makes the escalation vector concrete), but the decisions belong to the call protocol.
|
|
||||||
|
|
||||||
This OQ will be resolved with an ADR before alknet-call implementation begins.
|
|
||||||
- **Cross-references**: ADR-014, [call-protocol.md](crates/call/call-protocol.md), [operation-registry.md](crates/call/operation-registry.md)
|
|
||||||
|
|
||||||
### OQ-19: Session-Scoped Operation Registries and Agent-Written Operations
|
### OQ-19: Session-Scoped Operation Registries and Agent-Written Operations
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ The following types live in alknet-core and are used across handler crates:
|
|||||||
| `IdentityProvider` | Trait for resolving credentials to identity |
|
| `IdentityProvider` | Trait for resolving credentials to identity |
|
||||||
| `AuthToken` | Opaque authentication token |
|
| `AuthToken` | Opaque authentication token |
|
||||||
| `Capabilities` | Outbound credentials injected by the assembly layer (non-serializable, zeroized) |
|
| `Capabilities` | Outbound credentials injected by the assembly layer (non-serializable, zeroized) |
|
||||||
|
| `Visibility` | Operation visibility — External (wire-callable) or Internal (composition-only) |
|
||||||
| `StaticConfig` | Immutable configuration loaded at startup |
|
| `StaticConfig` | Immutable configuration loaded at startup |
|
||||||
| `DynamicConfig` | Hot-reloadable configuration (`ArcSwap`) |
|
| `DynamicConfig` | Hot-reloadable configuration (`ArcSwap`) |
|
||||||
| `ConfigReloadHandle` | Handle for triggering config reloads |
|
| `ConfigReloadHandle` | Handle for triggering config reloads |
|
||||||
@@ -203,6 +204,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
|||||||
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
| [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
|
||||||
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TypeScript reference adaptation |
|
| [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Rust canonical, TypeScript reference adaptation |
|
||||||
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry outbound credentials; call protocol carries no secret material |
|
| [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Capabilities carry outbound credentials; call protocol carries no secret material |
|
||||||
|
| [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user