Skip to content

Commit 589fc7b

Browse files
feat(auth): allow non-admin user role to use HTTP sink/IO nodes (#634)
* feat(auth): allow non-admin user role to use HTTP sink/IO nodes The built-in user role excluded transport::http::* entirely and all streamkit::* nodes, forcing HTTP-transport gateways to run with an admin token. Allow the safe sink/IO nodes (transport::http::mse, streamkit::http_input, streamkit::http_output) while keeping transport::http::fetcher denied (SSRF risk), and document a least-privilege gateway role. Closes #632 Signed-off-by: streamkit-devin <devin@streamkit.dev> * docs(auth): align sample skit.toml with safe HTTP node defaults Tighten the sample user role to deny transport::http::fetcher (was allowed via the transport::* wildcard) and add a least-privilege gateway role example. Signed-off-by: streamkit-devin <devin@streamkit.dev> * fix(auth): align sample user role with built-in secure default Mirror Permissions::user() in samples/skit.toml: drop blanket core::* (which exposed core::file_writer) for the safe core subset, add video::* and plugin usage, keeping transport::http::fetcher denied. Extend the sample config test to assert the user/gateway node allowlists. Signed-off-by: streamkit-devin <devin@streamkit.dev> * fix(auth): tighten gateway role core nodes and align user samples Drop core::* from the gateway example (it exposed core::file_writer, contradicting the least-privilege intent) for the safe core plumbing subset, and remove demo/* from the sample user role's allowed_samples to match the built-in Permissions::user(). Signed-off-by: streamkit-devin <devin@streamkit.dev> * fix(auth): make gateway role usable and align user role with built-in The gateway example could never run its own pipeline: is_node_allowed() runs before the plugin check, so plugin::native::servo (absent from allowed_nodes) was rejected, and the role lacked the video/containers nodes the servo->encode->mux->serve pipeline needs. Grant exactly those kinds. Also align the sample user role with Permissions::user(): add the list/read/write/delete_samples flags (without which allowed_samples is unreachable) and core::param_bridge (a safe in-graph node). Drop the streamkit::http_input/http_output allowlist entries from both the built-in and sample roles - they are oneshot-only markers never gated by is_node_allowed, so listing them was dead config. Signed-off-by: streamkit-devin <devin@streamkit.dev> * docs(auth): note audio encoder needed for gateway audio capture The documented gateway role serves a video-only WebM cast. Page audio would also need audio::opus::encoder in allowed_nodes, since the mse node advertises codecs="vp9,opus". Noted as a future extension. Signed-off-by: streamkit-devin <devin@streamkit.dev> * fix(auth): mirror built-in user asset policy in sample config The sample user role's allowed_assets only listed audio, while Permissions::user() also grants image and font assets. Add the image/font patterns so the sample matches the built-in default the comments claim to mirror, and assert the asset policy in sample_config_test. Signed-off-by: streamkit-devin <devin@streamkit.dev> * docs(auth): scope gateway role comment to web-capture example The comment claimed the role serves the speech-gateway too, but the node/plugin lists only cover web-capture. Clarify that the lists are web-capture-scoped and other gateways swap in their own nodes/plugins. Signed-off-by: streamkit-devin <devin@streamkit.dev> --------- Signed-off-by: streamkit-devin <devin@streamkit.dev> Co-authored-by: streamkit-devin <devin@streamkit.dev>
1 parent 2942c05 commit 589fc7b

4 files changed

Lines changed: 170 additions & 9 deletions

File tree

apps/skit/src/permissions.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,19 @@ impl Permissions {
162162
"containers::*".to_string(),
163163
// Transport: allow MoQ, deny HTTP fetcher by default (SSRF risk)
164164
"transport::moq::*".to_string(),
165-
// Core: explicitly allow safe-ish nodes; deny core::file_writer by default (arbitrary write risk)
165+
// Transport HTTP: allow `mse` (serves over the caller's own request, no
166+
// arbitrary-URL fetch) so least-privilege gateways can serve live casts
167+
// without admin. `transport::http::fetcher` stays denied (SSRF risk).
168+
// The oneshot `streamkit::http_input`/`http_output` markers are not gated by
169+
// this allowlist (the oneshot path treats them as implicitly allowed), so
170+
// they are intentionally not listed here.
171+
"transport::http::mse".to_string(),
172+
// Core: explicitly allow the safe nodes. Omitted on purpose:
173+
// `core::file_writer` / `core::object_store_writer` (arbitrary/external write).
166174
"core::passthrough".to_string(),
167175
"core::file_reader".to_string(),
168176
"core::pacer".to_string(),
177+
"core::param_bridge".to_string(),
169178
"core::json_serialize".to_string(),
170179
"core::text_chunker".to_string(),
171180
"core::script".to_string(),
@@ -511,6 +520,20 @@ mod tests {
511520
assert!(user.is_node_allowed("plugin::wasm::gain_filter_rust"));
512521
}
513522

523+
#[test]
524+
fn test_default_user_http_and_core_node_policy() {
525+
let user = Permissions::user();
526+
// `mse` serves over the caller's own request and is safe to allow.
527+
assert!(user.is_node_allowed("transport::http::mse"));
528+
// Safe in-graph core nodes (no external side effects) are allowed.
529+
assert!(user.is_node_allowed("core::param_bridge"));
530+
// The HTTP fetcher stays denied (arbitrary-URL fetch / SSRF risk), and the
531+
// write-capable core nodes stay denied (arbitrary / external write).
532+
assert!(!user.is_node_allowed("transport::http::fetcher"));
533+
assert!(!user.is_node_allowed("core::file_writer"));
534+
assert!(!user.is_node_allowed("core::object_store_writer"));
535+
}
536+
514537
#[test]
515538
fn test_global_session_limits() {
516539
let config = PermissionsConfig { max_concurrent_sessions: Some(10), ..Default::default() };

apps/skit/tests/sample_config_test.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ fn samples_skit_toml_parses_and_matches_expected_defaults() {
5050
assert!(config.permissions.roles.contains_key("admin"));
5151
assert!(config.permissions.roles.contains_key("demo"));
5252
assert!(config.permissions.roles.contains_key("user"));
53+
assert!(config.permissions.roles.contains_key("gateway"));
5354
assert!(config.permissions.roles.contains_key("readonly"));
5455

5556
let readonly = config.permissions.get_role("readonly");
@@ -58,5 +59,35 @@ fn samples_skit_toml_parses_and_matches_expected_defaults() {
5859
assert!(!readonly.upload_assets);
5960
assert!(!readonly.delete_assets);
6061

62+
// The user role mirrors Permissions::user(): the allowed_samples list is
63+
// reachable (sample flags set), the HTTP `mse` sink is allowed, but the
64+
// SSRF-risk fetcher and the write-capable core nodes stay denied.
65+
let user = config.permissions.get_role("user");
66+
assert!(user.list_samples && user.read_samples && user.write_samples && user.delete_samples);
67+
assert!(user.is_node_allowed("transport::http::mse"));
68+
assert!(user.is_node_allowed("core::param_bridge"));
69+
assert!(!user.is_node_allowed("transport::http::fetcher"));
70+
assert!(!user.is_node_allowed("core::file_writer"));
71+
assert!(!user.is_node_allowed("core::object_store_writer"));
72+
// Asset policy also mirrors the built-in: audio, images and fonts.
73+
assert!(user.is_asset_allowed("samples/audio/system/beep.wav"));
74+
assert!(user.is_asset_allowed("samples/images/system/logo.png"));
75+
assert!(user.is_asset_allowed("samples/fonts/system/inter.ttf"));
76+
77+
// The gateway role is least-privilege but must actually be able to build the
78+
// servo -> encode -> mux -> serve pipeline. Crucially the plugin kind has to
79+
// pass is_node_allowed() (checked before the plugin allowlist), and the
80+
// fetcher / file_writer stay denied.
81+
let gateway = config.permissions.get_role("gateway");
82+
assert!(gateway.create_sessions);
83+
assert!(!gateway.load_plugins);
84+
assert!(gateway.is_node_allowed("plugin::native::servo"));
85+
assert!(gateway.is_plugin_allowed("plugin::native::servo"));
86+
assert!(gateway.is_node_allowed("video::vp9::encoder"));
87+
assert!(gateway.is_node_allowed("containers::webm::muxer"));
88+
assert!(gateway.is_node_allowed("transport::http::mse"));
89+
assert!(!gateway.is_node_allowed("transport::http::fetcher"));
90+
assert!(!gateway.is_node_allowed("core::file_writer"));
91+
6192
assert!(config.script.global_fetch_allowlist.is_empty());
6293
}

docs/src/content/docs/guides/authorization.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,47 @@ allowed_assets = ["*"]
109109
> [!NOTE]
110110
> Role permissions are deny-by-default. If you define a custom role in `skit.toml`, any permission you omit defaults to `false`.
111111
112+
> [!NOTE]
113+
> The built-in `user` role allows `transport::http::mse` (live-cast playback over the caller's own request) but **not** `transport::http::fetcher`, which can fetch arbitrary URLs (SSRF risk). The oneshot `streamkit::http_input` / `streamkit::http_output` markers are always permitted on the oneshot path regardless of `allowed_nodes`, so they need no allowlist entry. A trusted gateway that only serves or receives over the caller's own request therefore does not need `admin`.
114+
115+
## Example: Least-privilege gateway role
116+
117+
Trusted intermediaries (e.g. the `web-capture` or `speech-gateway` examples) build a small set of fixed pipelines and should run with a scoped token instead of `admin`. The role below grants exactly the node kinds the web-capture pipeline (`servo → encode → mux → serve`) needs and nothing more.
118+
119+
> [!IMPORTANT]
120+
> A plugin must appear in **both** `allowed_nodes` and `allowed_plugins`. Enforcement calls `is_node_allowed(kind)` *before* the plugin check, so a plugin kind missing from `allowed_nodes` is rejected before `allowed_plugins` is ever consulted.
121+
122+
```toml
123+
[permissions.roles.gateway]
124+
create_sessions = true
125+
destroy_sessions = true
126+
modify_sessions = true
127+
tune_nodes = true
128+
list_sessions = true
129+
list_nodes = true
130+
access_all_sessions = false # Only its own sessions
131+
load_plugins = false
132+
delete_plugins = false
133+
upload_assets = false
134+
delete_assets = false
135+
allowed_nodes = [
136+
"plugin::native::servo", # render the page (web-capture)
137+
"video::pixel_convert", # servo RGBA -> encoder input format
138+
"video::vp9::encoder", # encode to VP9
139+
"containers::webm::muxer", # mux into WebM for MSE / http_output
140+
"transport::http::mse", # serve the live cast to the browser (MSE)
141+
"core::pacer",
142+
"core::sink",
143+
# No core::file_writer (arbitrary-write risk) and no transport::http::fetcher (SSRF).
144+
# The oneshot streamkit::http_output marker is implicitly allowed.
145+
]
146+
allowed_plugins = ["plugin::native::servo"] # must also be listed in allowed_nodes (see note above)
147+
```
148+
149+
This role serves a **video-only** WebM cast. To also carry page audio (the `mse`
150+
node advertises `codecs="vp9,opus"`), add the audio encoder — e.g.
151+
`"audio::opus::encoder"` — to `allowed_nodes`.
152+
112153
## Permission reference
113154

114155
| Permission | Description |

samples/skit.toml

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -479,35 +479,101 @@ delete_plugins = false
479479
list_nodes = true
480480
access_all_sessions = false
481481

482-
# Users can access all samples except admin-only ones
482+
# Sample management (mirrors the built-in Permissions::user()); without these
483+
# flags the allowed_samples list below would be unreachable.
484+
list_samples = true
485+
read_samples = true
486+
write_samples = true
487+
delete_samples = true
488+
489+
# Users can access standard samples (mirrors the built-in Permissions::user()).
483490
allowed_samples = [
484491
"oneshot/*.yml",
485492
"oneshot/*.yaml",
486493
"dynamic/*.yml",
487494
"dynamic/*.yaml",
488-
"demo/*.yml",
489-
"demo/*.yaml",
490495
"user/*.yml",
491496
"user/*.yaml",
492497
]
493498

494-
# Users can use most nodes except potentially dangerous ones
499+
# Users can use most nodes except potentially dangerous ones. Mirrors the
500+
# built-in Permissions::user() default. Allowed: MoQ + the HTTP `mse` sink
501+
# (serves over the caller's own request). Denied by omission:
502+
# transport::http::fetcher (SSRF), core::file_writer / core::object_store_writer
503+
# (arbitrary/external write). The oneshot streamkit::http_input/http_output
504+
# markers are implicitly allowed on the oneshot path and need no entry here.
495505
allowed_nodes = [
496506
"audio::*",
497-
"transport::*",
498-
"core::*",
507+
"video::*",
499508
"containers::*",
509+
"transport::moq::*",
510+
"transport::http::mse",
511+
"core::passthrough",
512+
"core::file_reader",
513+
"core::pacer",
514+
"core::param_bridge",
515+
"core::json_serialize",
516+
"core::text_chunker",
517+
"core::script",
518+
"core::telemetry_tap",
519+
"core::telemetry_out",
520+
"core::sink",
521+
"plugin::*",
500522
]
501523

502-
# Users cannot load plugins, so this list is empty
503-
allowed_plugins = []
524+
# Users cannot load/delete plugins, but may use plugins an admin has already loaded.
525+
allowed_plugins = ["plugin::*"]
504526

505527
# Users can list bundled system assets and their uploaded assets
528+
# (mirrors the built-in Permissions::user(): audio, images and fonts).
506529
allowed_assets = [
507530
"samples/audio/system/*",
508531
"samples/audio/user/*",
532+
"samples/images/system/*",
533+
"samples/images/user/*",
534+
"samples/fonts/system/*",
535+
"samples/fonts/user/*",
536+
]
537+
538+
[permissions.roles.gateway]
539+
# Least-privilege role for trusted intermediaries that build a small set of
540+
# fixed pipelines, so they need not run as admin. The node/plugin lists below
541+
# are scoped to the web-capture example; a different gateway (e.g. speech-gateway)
542+
# would swap in its own nodes and plugins (audio nodes, whisper/kokoro, etc.).
543+
create_sessions = true
544+
destroy_sessions = true
545+
list_sessions = true
546+
modify_sessions = true
547+
tune_nodes = true
548+
load_plugins = false
549+
delete_plugins = false
550+
list_nodes = true
551+
access_all_sessions = false # Only its own sessions
552+
upload_assets = false
553+
delete_assets = false
554+
555+
allowed_samples = []
556+
557+
# Exactly the node kinds the web-capture / live-cast pipeline needs, nothing more.
558+
# A plugin kind must be allowed here too: is_node_allowed() runs before the
559+
# allowed_plugins check, so a plugin missing from allowed_nodes is rejected first.
560+
# No fetcher (SSRF) and no file_writer (arbitrary write). The oneshot
561+
# streamkit::http_output marker is implicitly allowed and needs no entry.
562+
allowed_nodes = [
563+
"plugin::native::servo", # render the page (web-capture)
564+
"video::pixel_convert", # servo RGBA -> encoder input format
565+
"video::vp9::encoder", # encode to VP9
566+
"containers::webm::muxer", # mux into WebM for MSE / http_output
567+
"transport::http::mse", # serve the live cast to the browser (MSE)
568+
"core::pacer",
569+
"core::sink",
509570
]
510571

572+
# Only the specific plugin(s) the gateway needs (example: servo for web-capture).
573+
allowed_plugins = ["plugin::native::servo"]
574+
575+
allowed_assets = []
576+
511577
[permissions.roles.readonly]
512578
# Read-only role - can only view, not modify
513579
create_sessions = false

0 commit comments

Comments
 (0)