Skip to content

feat(auth): emit OCSF events for gateway authorization denials - #4728

Merged
krishicks merged 1 commit into
mainfrom
feat/2912-authz-denial-events/krishicks
Oct 20, 2026
Merged

krishicks merged 1 commit into
mainfrom
feat/2912-authz-denial-events/krishicks

Conversation

@krishicks

@krishicks krishicks commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

When the gateway refuses a caller that has already authenticated, it now emits an OCSF API Activity event. The caller gets exactly the same error as before. The event records:

  • Who: the principal (user, mTLS operator, sandbox, or gateway peer).
  • What: the gRPC method, plus the sandbox or workspace that was refused when there is one.
  • Why: a reason such as cross_sandbox, missing_role or not_workspace_member.
  • Correlation: the caller's address and request ID, the same fields the authentication events carry.

That covers the denials made centrally by the router and the shared guards, plus the checks inside handlers that have no shared guard: a sandbox naming another sandbox (in either sandbox-name resolver), a non-user caller refused by mutation admission, a sandbox sending a config update it isn't allowed to, and a non-admin granting the workspace admin role. Denials raised in the mutation admission worker, which runs in a spawned task, keep the request's context.

When a sandbox credential is refused for calling a method it may not call, touching another sandbox, or acting across workspaces, the gateway also emits a High-severity Detection Finding. It carries the same correlation_uid as the denial event, which covers the two "dual-event" criteria in #2912. A real supervisor never makes these requests, so a finding means the credential is probably being misused. Two cases are recorded without a finding: a method the gateway doesn't recognize (unknown_method), so a newer supervisor doesn't look like a compromised one, and a supervisor whose own sandbox was deleted mid-request, which is refused but isn't misuse.

The shorthand line names the refused sandbox or workspace, for example sandbox:<id>, so operators reading console logs can see the target without JSONL or OTLP export.

To support this, the OCSF crate's Actor can now hold a user instead of a process, and the API Activity builder gained the fields gateway events need. Supervisor inference events come out unchanged. The OCSF 1.8.0 resource_details schema is vendored so the refused sandbox or workspace goes in the standard resources field.

Related Issue

Refs #2912

This is the fourth of several PRs that together satisfy #2912, so it does not close the issue. The remaining work is listed under Follow-ups.

Changes

  • Where denials are recorded. Each denial is emitted where it is decided:

    • The router's principal-type checks and RBAC role and scope checks. AuthzPolicy::check now returns an RbacDenial that says which one failed.
    • The shared guards that handlers call: sandbox ownership (ensure_sandbox_scope), supervisor-only methods, operator-only methods, workspace membership and role, and platform admin.
    • Handler checks with no shared guard: a sandbox naming another sandbox in a name-addressed RPC (resolve_and_authorize_sandbox_name, used by over 30 handlers, and SubmitPolicyAnalysis's own resolver), a non-user caller sending a request_id to mutation admission, a sandbox caller sending a non-sync UpdateConfig, and a non-platform-admin assigning the workspace admin role. Both name resolvers distinguish a deleted caller record from a name that belongs to another sandbox, so deletion races don't raise findings.
    • PushSandboxLogs now runs the sandbox-scope guard on every batch, so a stream that switches to another sandbox's ID after its first batch is recorded.

    The router runs each handler inside a task-local scope holding the request path and origin, so guards record them without new parameters at their ~80 call sites. audit::in_current_request carries that scope into the mutation admission worker's spawned task.

  • Event contents.

    • actor.user is the principal: User (OIDC subject), Admin (mTLS operator), Sandbox (sandbox ID), or Gateway Peer (replica ID).
    • api.operation and api.service come from the gRPC path.
    • resources names the sandbox or workspace, when one applies: a sandbox ID goes in uid, and a sandbox or workspace name goes in name. Request-supplied identifiers are capped at 128 bytes.
    • status_detail is one of method_not_permitted, unknown_method, missing_role, missing_scope, cross_sandbox, not_workspace_member, insufficient_workspace_role, platform_admin_required, or operator_required.
    • src_endpoint and metadata.correlation_uid match the authentication events.
  • Findings. A sandbox principal refused with method_not_permitted, cross_sandbox, or platform_admin_required also emits a Detection Finding (sandbox-authorization-boundary). Supervisors never make these requests. When the request has no usable x-request-id, the pair shares a generated ID.

  • Not-found responses. Methods that answer a refused request with "not found", to avoid revealing which resources exist, still record the denial.

  • openshell-ocsf:

    • Actor can carry a user instead of a process, and both fields are now optional.
    • ApiActivityBuilder gains actor_user, src_endpoint, action/disposition, status_detail, correlation_uid, api_service, and resource. Supervisor inference events are unchanged.
    • Gateway-origin API Activity and Detection Finding events declare the container profile only when they carry a container, that is when they concern a sandbox. Authorization denials don't; MXC findings still do.
    • New ResourceDetails object (by uid or by name), with the 1.8.0 resource_details schema vendored.
    • Shorthand for gateway API Activity events: API:OTHER [MED] DENIED sandbox:<id> from <peer> [op:<path> <resource-type>:<id> reason:<category>].
  • Removed. The plain info! cross-sandbox log line, now replaced by the OCSF event.

  • Docs. ocsf-json-export.mdx documents the event, the reason table, and the finding. logging.mdx adds the API: prefix and an example.

Events never include credentials, request bodies, or headers other than the canonical request ID.

Testing

  • cargo nextest run -p openshell-server -p openshell-ocsf: all 2,594 tests pass.
  • cargo clippy --workspace --all-targets -- -D warnings
  • Unit tests added or updated:
    • A sandbox principal on an admin method emits the denial plus a finding, with the same correlation ID and no credential canary in either.
    • A missing RBAC role emits a denial and no finding.
    • An unknown method emits unknown_method and no finding.
    • A permitted sandbox call emits no event.
    • Operator and gateway-peer denials name their principal type.
    • A sandbox naming another sandbox (through either resolver), and a log stream switching sandbox IDs mid-stream, each emit a cross_sandbox denial and a finding.
    • A supervisor whose own sandbox was deleted is refused without a finding.
    • A sandbox sending a request_id to mutation admission emits a denial and a finding.
    • A denial raised inside the mutation admission worker keeps the request's operation, source, and request ID.
    • A denial raised inside a handler is recorded against the routed path and source.
    • Workspace denials name the workspace.
    • A sandbox principal's cross-workspace request raises a finding.
    • The events validate against the vendored 1.8.0 schemas.
  • E2E tests added or updated: not applicable.

openshell-supervisor-network's mediated_connect_keeps_workload_bytes_read_with_the_synthesized_header fails locally on main as well. This PR doesn't cause it.

Follow-ups

  • Opt-in authentication success events.
  • Remaining authentication paths: SSH tokens and IssueSandboxToken. The principal-type and runtime-identity checks inside IssueSandboxToken, RefreshSandboxToken, and GetCurrentUser move with them, as does the "extension service not selected by the sandbox policy" refusal in RefreshSandboxToken.
  • Source address of the edge tunnel's own connection on tunneled requests.
  • Denials raised while a streaming RPC's response body is being polled are recorded without the request path, because that runs outside the handler's task-local scope. No guard runs there today.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Docs updated

@github-actions

Copy link
Copy Markdown

@krishicks
krishicks force-pushed the feat/2912-authz-denial-events/krishicks branch 2 times, most recently from 56c9019 to 37d4e7d Compare October 19, 2026 23:55
@TaylorMutch TaylorMutch self-assigned this Oct 20, 2026
@TaylorMutch

TaylorMutch commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

@krishicks Did some spelunking with my agent and it got caught up on an issue about the container profile not being included, which sounds okay to me. Here's what I got:


Nonblocking question: The new gateway finding correctly omits container information. Existing Windows MXC findings use the same builder with sandbox context, though, so they still emit a container object without the container profile declaration. Is that intentional, or should the builder also omit that object for gateway-origin events?

This does not affect correlation, and I have not demonstrated a downstream consumer failure, so I would not hold up this PR on this point.

@TaylorMutch

Copy link
Copy Markdown
Collaborator

Otherwise this makes sense to me

TaylorMutch
TaylorMutch previously approved these changes Oct 20, 2026
@TaylorMutch TaylorMutch added the test:e2e Requires end-to-end coverage label Oct 20, 2026
@github-actions

Copy link
Copy Markdown

Label test:e2e applied for 37d4e7d. Open the existing run and click Re-run all jobs to execute with the label set. The run will execute the standard E2E suite after building the required gateway, sandbox, and supervisor images once. The matching required CI gate status on this PR will flip green automatically once the run finishes.

When the gateway refuses a caller that has already authenticated, it
now emits an OCSF API Activity event. The caller gets exactly the same
error as before. The event records:

- Who: the principal (user, mTLS operator, sandbox, or gateway peer).
- What: the gRPC method, plus the sandbox or workspace that was
  refused when there is one.
- Why: a reason such as cross_sandbox, missing_role or
  not_workspace_member.
- Correlation: the caller's address and request ID, the same fields
  the authentication events carry.

That covers the denials made centrally by the router and the shared
guards, plus the checks inside handlers that have no shared guard: a
sandbox naming another sandbox (in either sandbox-name resolver), a
non-user caller refused by mutation admission, a sandbox sending a
config update it isn't allowed to, and a non-admin granting the
workspace admin role. Denials raised in the mutation admission worker,
which runs in a spawned task, keep the request's context.

When a sandbox credential is refused for calling a method it may not
call, touching another sandbox, or acting across workspaces, the
gateway also emits a High-severity Detection Finding. It carries the
same correlation_uid as the denial event. A real supervisor never makes
these requests, so a finding means the credential is probably being
misused. Two cases are recorded without a finding: a method the
gateway doesn't recognize (unknown_method), so a newer supervisor
doesn't look like a compromised one, and a supervisor whose own sandbox
was deleted mid-request, which is refused but isn't misuse.

The shorthand line names the refused sandbox or workspace, for example
sandbox:<id>, so operators reading console logs can see the target
without JSONL or OTLP export.

To support this, the OCSF crate's Actor can now hold a user instead of
a process, and the API Activity builder gained the fields gateway
events need. Supervisor inference events come out unchanged. Gateway
events declare the container profile only when they concern a sandbox,
as MXC findings do. The OCSF
1.8.0 resource_details schema is vendored so the refused sandbox or
workspace goes in the standard resources field.

Refs #2912

Signed-off-by: Kris Hicks <khicks@nvidia.com>
@krishicks
krishicks force-pushed the feat/2912-authz-denial-events/krishicks branch from 37d4e7d to 5a19aef Compare October 20, 2026 14:40
@krishicks
krishicks added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit a24340b Oct 20, 2026
123 of 124 checks passed
@krishicks
krishicks deleted the feat/2912-authz-denial-events/krishicks branch October 20, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants